unoffical wafrn mirror wafrn.net
atproto social-network activitypub
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: option to play a game of fifteen to open CW

+314 -11
+13
packages/frontend/src/app/components/dialog/confirm-dialog.component.html
··· 12 12 <strong class="dialog-content-suffix">{{ textData.contentSuffix | translate }}</strong> 13 13 </p> 14 14 } 15 + @if (data.annoying === Annoyance.fifteen) { 16 + <hr /> 17 + <p>{{ 'dialog.post-fragment.annoyances.puzzleWarning' | translate }}</p> 18 + <div class="game flex justify-content-center"> 19 + <app-fifteen-game 20 + [options]="{ 21 + width: 3, 22 + height: 3 23 + }" 24 + (won)="winAnnoyance()" 25 + ></app-fifteen-game> 26 + </div> 27 + } 15 28 </mat-dialog-content> 16 29 } 17 30 <mat-dialog-actions>
+29 -5
packages/frontend/src/app/components/dialog/confirm-dialog.component.ts
··· 10 10 import { MatFormFieldModule } from '@angular/material/form-field' 11 11 import { MatInputModule } from '@angular/material/input' 12 12 import { TranslatePipe } from '@ngx-translate/core' 13 - import { timer } from 'rxjs' 13 + import { Subject, timer } from 'rxjs' 14 + import { FifteenGameComponent } from '../fifteen-game/fifteen-game.component' 15 + import { ParticleService } from 'src/app/services/particle.service' 14 16 15 17 export enum Annoyance { 16 18 none = '0', 17 - timeout = '1' 19 + timeout = '1', 20 + fifteen = '2' 18 21 } 19 22 20 23 export interface ConfirmDialogData { ··· 39 42 MatDialogContent, 40 43 MatFormFieldModule, 41 44 MatInputModule, 45 + FifteenGameComponent, 42 46 TranslatePipe 43 47 ], 44 48 templateUrl: './confirm-dialog.component.html' ··· 51 55 textData: ConfirmDialogData 52 56 confirmButtonEnabled: boolean 53 57 58 + // Annoyance data 59 + annoyanceComplete = new Subject<void>() 60 + 54 61 // Defaults for the buttons 55 62 defaultTextData = { 56 63 options: { ··· 61 68 62 69 inputResponse = signal('') 63 70 64 - constructor(@Inject(MAT_DIALOG_DATA) protected data: ConfirmDialogData) { 71 + // Type mirroring for component 72 + Annoyance = Annoyance 73 + 74 + constructor( 75 + @Inject(MAT_DIALOG_DATA) protected data: ConfirmDialogData, 76 + private particle: ParticleService 77 + ) { 65 78 this.textData = Object.assign(this.defaultTextData, data) 66 - this.confirmButtonEnabled = data.annoying !== Annoyance.timeout 79 + 80 + // Disable button if there's additional conditions 81 + this.confirmButtonEnabled = data.annoying !== undefined && data.annoying === Annoyance.none 82 + this.annoyanceComplete.subscribe(() => { 83 + this.confirmButtonEnabled = true 84 + }) 67 85 68 86 // Various annoyances 87 + 69 88 if (data.annoying === Annoyance.timeout) { 70 89 timer(2000).subscribe(() => { 71 - this.confirmButtonEnabled = true 90 + this.annoyanceComplete.next() 72 91 }) 73 92 } 93 + } 94 + 95 + winAnnoyance() { 96 + this.particle.genericConfetti() 97 + this.annoyanceComplete.next() 74 98 } 75 99 76 100 onInput(event: InputEvent): void {
+23
packages/frontend/src/app/components/fifteen-game/fifteen-game.component.html
··· 1 + <div class="game"> 2 + <div class="header flex justify-content-between"> 3 + <p class="mb-0 game-name">{{ 'games.fifteen.title' | translate }}</p> 4 + <div class="moves">{{ moves }} {{ 'games.common.moves' | translate }}</div> 5 + </div> 6 + <div class="board mb-1" style="--grid-width: {{ opts.width }};"> 7 + @for (tile of board; track $index) { 8 + @if (tile === 0) { 9 + <div class="tile blank"></div> 10 + } @else { 11 + <button class="tile" (click)="handleTile($index)" [disabled]="!tileClickable($index)">{{ tile }}</button> 12 + } 13 + } 14 + </div> 15 + <div class="footer flex gap-2 justify-content-between"> 16 + <button mat-flat-button class="game-button mat-small-button" (click)="newBoard()">{{ 17 + 'games.fifteen.newBoard' | translate 18 + }}</button> 19 + <button mat-stroked-button class="game-button mat-small-button" (click)="resetBoard()">{{ 20 + 'games.fifteen.resetBoard' | translate 21 + }}</button> 22 + </div> 23 + </div>
+42
packages/frontend/src/app/components/fifteen-game/fifteen-game.component.scss
··· 1 + .game { 2 + padding: 8px; 3 + background-color: var(--mat-sys-outline-variant); 4 + background-color: var(--mat-sys-surface-container-highest); 5 + border: 1px solid var(--mat-sys-outline-variant); 6 + } 7 + 8 + .game-name { 9 + font-weight: bold; 10 + } 11 + 12 + .board { 13 + display: grid; 14 + grid-template-columns: repeat(var(--grid-width), 1fr); 15 + gap: 2px; 16 + padding: 2px; 17 + background-color: var(--mat-sys-outline); 18 + font-size: 1.25rem; 19 + } 20 + 21 + .tile { 22 + padding: 0; 23 + aspect-ratio: 1/1; 24 + background-color: var(--mat-sys-surface-container-highest); 25 + color: inherit; 26 + border: unset; 27 + box-shadow: 28 + 4px 4px 4px 2px var(--mat-sys-background) inset, 29 + -4px -4px 8px -6px var(--mat-sys-shadow) inset; 30 + cursor: pointer; 31 + font-size: inherit; 32 + user-select: none; 33 + } 34 + 35 + .blank, 36 + .tile[disabled] { 37 + cursor: unset; 38 + } 39 + 40 + .blank { 41 + box-shadow: unset; 42 + }
+167
packages/frontend/src/app/components/fifteen-game/fifteen-game.component.ts
··· 1 + import { Component, input, output } from '@angular/core' 2 + import { MatButtonModule } from '@angular/material/button' 3 + import { TranslatePipe } from '@ngx-translate/core' 4 + 5 + type Coordinate = [number, number] 6 + 7 + export type FifteenOptions = { 8 + width: number 9 + height: number 10 + scrambleCount: number 11 + } 12 + 13 + @Component({ 14 + selector: 'app-fifteen-game', 15 + imports: [MatButtonModule, TranslatePipe], 16 + templateUrl: './fifteen-game.component.html', 17 + styleUrl: './fifteen-game.component.scss' 18 + }) 19 + export class FifteenGameComponent { 20 + won = output() 21 + options = input<Partial<FifteenOptions>>() // make the input name nicer 22 + 23 + // just in case we make this not fifteen but w*h-1 24 + opts: FifteenOptions 25 + defaultOptions = { 26 + width: 3, 27 + height: 3, 28 + scrambleCount: 101 29 + } 30 + 31 + moves = 0 32 + 33 + wonBoard: number[] = [] 34 + board: number[] = [] 35 + blankIndex: number = 0 36 + initialBoard: number[] = [] 37 + initialBlankIndex: number = 0 38 + 39 + constructor() { 40 + this.opts = this.defaultOptions 41 + } 42 + 43 + ngOnInit() { 44 + this.opts = Object.assign(this.defaultOptions, this.options()) 45 + this.wonBoard = [...[...Array(this.opts.width * this.opts.height).keys()].slice(1), 0] 46 + 47 + this.newBoard() 48 + this.initialBoard = [...this.board] 49 + this.initialBlankIndex = this.blankIndex 50 + } 51 + 52 + win() { 53 + this.won.emit() 54 + } 55 + 56 + handleTile(index: number) { 57 + this.swapTiles(this.indexToCoord(index), this.indexToCoord(this.blankIndex)) 58 + this.blankIndex = index 59 + 60 + this.moves += 1 61 + 62 + // Check if we won I guess 63 + if (this.boardWon()) this.win() 64 + } 65 + 66 + // 67 + // Game functionality 68 + // 69 + newBoard() { 70 + this.moves = 0 71 + 72 + // Try up to 20 boards to find one that isn't auto-solved already 73 + let checkCount = 0 74 + do { 75 + this.board = [...[...Array(this.opts.width * this.opts.height).keys()].slice(1), 0] // [1...n-1,0] 76 + this.blankIndex = this.opts.width * this.opts.height - 1 // Last time is 0 (blank) 77 + for (let i = 0; i < this.opts.scrambleCount; i++) { 78 + this.randomSwap(this.blankIndex) 79 + } 80 + } while (this.boardWon() && checkCount++ < 20) 81 + } 82 + 83 + resetBoard() { 84 + this.board = [...this.initialBoard] 85 + this.blankIndex = this.initialBlankIndex 86 + this.moves = 0 87 + } 88 + 89 + tileClickable(index: number): boolean { 90 + // Can't click the blank tile also 91 + if (index === this.blankIndex) return false 92 + 93 + // Ensure index is adjacent 94 + const adjacentIndices = this.getAdjacentIndices(this.blankIndex) 95 + return adjacentIndices.includes(index) 96 + } 97 + 98 + // start: tile to swap with an adjacent tile 99 + private randomSwap(startIndex: number) { 100 + const adjacentTiles: number[] = this.getAdjacentIndices(startIndex) 101 + 102 + const nextIndex = adjacentTiles.at(Math.floor(Math.random() * adjacentTiles.length)) 103 + if (nextIndex === undefined) return 104 + 105 + this.swapTiles(this.indexToCoord(startIndex), this.indexToCoord(nextIndex)) 106 + this.blankIndex = nextIndex 107 + } 108 + 109 + // Helpers 110 + 111 + // Converts index to coordinate if valid or null if invalid 112 + private indexToCoord(index: number): Coordinate | null { 113 + if (index >= this.opts.width * this.opts.height) return null 114 + return [index % this.opts.width, Math.floor(index / this.opts.width)] 115 + } 116 + 117 + // Converts coordinate to index if valid or null if invalid 118 + private coordToIndex(pos: Coordinate): number | null { 119 + const [x, y] = pos 120 + if (x < 0 || y < 0 || x >= this.opts.width || y >= this.opts.width) return null 121 + return x + y * this.opts.height 122 + } 123 + 124 + // Swaps two tiles as longs as they are both valid 125 + private swapTiles(from: Coordinate | null, to: Coordinate | null) { 126 + if (from === null || to === null) return 127 + 128 + const fromIndex = this.coordToIndex(from) 129 + const toIndex = this.coordToIndex(to) 130 + if (fromIndex === null || toIndex === null) return 131 + 132 + const temp = this.board[toIndex] 133 + this.board[toIndex] = this.board[fromIndex] 134 + this.board[fromIndex] = temp 135 + } 136 + 137 + // Result of adding two coordinates or null if off the edge 138 + private coordAdd(a: Coordinate, b: Coordinate): Coordinate | null { 139 + const res: Coordinate = [a[0] + b[0], a[1] + b[1]] 140 + if (res[0] < 0 || res[1] < 0 || res[0] >= this.opts.width || res[1] >= this.opts.height) return null 141 + return res 142 + } 143 + 144 + private getAdjacentIndices(index: number): number[] { 145 + const startCoord = this.indexToCoord(index) 146 + if (startCoord === null) return [] 147 + 148 + // Evil way to filter to tiles that can be picked 149 + return ( 150 + [ 151 + [0, 1], 152 + [1, 0], 153 + [0, -1], 154 + [-1, 0] 155 + ] as Coordinate[] 156 + ) 157 + .map((c) => this.coordAdd(c, startCoord)) 158 + .filter((v) => v !== null) 159 + .map((c) => this.coordToIndex(c)) 160 + .filter((v) => v !== null) 161 + } 162 + 163 + // If the board is won 164 + private boardWon(): boolean { 165 + return this.board.every((tile, i) => tile === this.wonBoard[i]) 166 + } 167 + }
+10
packages/frontend/src/app/services/particle.service.ts
··· 123 123 } 124 124 }) 125 125 } 126 + 127 + genericConfetti() { 128 + this.confetti({ 129 + config: { 130 + shapes: ['star'], 131 + colors: ['#d2849c', '#70b07d', '#73a1dc'], 132 + scalar: 2 133 + } 134 + }) 135 + } 126 136 }
+4 -2
packages/frontend/src/app/services/settings.service.ts
··· 575 575 confirmOpenCw: { 576 576 key: 'confirmOpenCw', 577 577 translationKey: 'settings.confirmOpenCw', 578 + translationDescriptionKey: 'settings.confirmOpenCwDescription', 578 579 serverKey: 'wafrn.confirmOpenCw', 579 580 localStorageKey: 'confirmOpenCw', 580 581 type: 'checkbox', ··· 586 587 serverKey: 'wafrn.confirmOpenCwAnnoyance', 587 588 localStorageKey: 'confirmOpenCwAnnoyance', 588 589 type: 'select', 589 - default: '1', 590 + default: Annoyance.none, 590 591 variants: { 591 592 [Annoyance.none]: 'settings.confirmOpenCwAnnoyanceOptions.none', 592 - [Annoyance.timeout]: 'settings.confirmOpenCwAnnoyanceOptions.timeout' 593 + [Annoyance.timeout]: 'settings.confirmOpenCwAnnoyanceOptions.timeout', 594 + [Annoyance.fifteen]: 'settings.confirmOpenCwAnnoyanceOptions.fifteen' 593 595 } 594 596 } 595 597 }
+7
packages/frontend/src/app/styles/material-overrides.scss
··· 58 58 pointer-events: none; 59 59 } 60 60 61 + // Add small button style 62 + :root .mat-small-button { 63 + height: unset; 64 + padding-inline: 4px; 65 + border-radius: var(--mat-sys-corner-small); 66 + } 67 + 61 68 // Fix drawer border radius without requiring themes to use !important, though uses 0 2 0 specificity 62 69 :root .mat-drawer { 63 70 border-top-right-radius: 0;
+19 -4
packages/frontend/src/assets/i18n/en.json
··· 400 400 "confettiTestLocational": "Under Mouse", 401 401 "confettiTestGeneric": "Screen sides", 402 402 "confirmOpenCw": "Show dialog before opening content warnings", 403 - "confirmOpenCwDescription": "Level of annoyance can be customized", 403 + "confirmOpenCwDescription": "If you open CWs without thinking.", 404 + "confirmOpenCwAnnoyance": "Type of additional annoyance", 404 405 "confirmOpenCwAnnoyanceOptions": { 405 - "none": "None", 406 - "timeout": "2 second timeout" 406 + "none": "Just the dialog", 407 + "timeout": "2 second timeout", 408 + "fifteen": "Solve a game of 3x3 Fifteen (EVIL)" 407 409 } 408 410 }, 409 411 "dialog": { ··· 451 453 }, 452 454 "post-fragment": { 453 455 "confirmOpenCwTitle": "Confirm opening content warning", 454 - "confirmOpenCwContent": "Post has the content warnings and muted words:" 456 + "confirmOpenCwContent": "Post has the content warnings and muted words:", 457 + "annoyances": { 458 + "puzzleWarning": "Solve this to prove you aren't making a bad decision:" 459 + } 455 460 }, 456 461 "blog": { 457 462 "customThemeTitle": "Custom Styles", ··· 595 600 "subtitle": "Looks like there isn't anything here.", 596 601 "couldNotFind": "does not lead to a page", 597 602 "returnHome": "Go Home" 603 + }, 604 + "games": { 605 + "common": { 606 + "moves": "Moves" 607 + }, 608 + "fifteen": { 609 + "title": "Fifteen", 610 + "newBoard": "New board", 611 + "resetBoard": "Reset board" 612 + } 598 613 } 599 614 }