wordle-xdc/src/Game.vue
2022-01-24 00:48:12 +08:00

334 lines
6.7 KiB
Vue

<script setup lang="ts">
import { onUnmounted } from 'vue'
import { answers, allWords } from './words'
import Keyboard from './Keyboard.vue'
import { LetterState } from './types'
// get word of the day
const now = new Date()
const start = new Date(2022, 0, 0)
const diff = Number(now) - Number(start)
let day = Math.floor(diff / (1000 * 60 * 60 * 24))
while (day > answers.length) {
day -= answers.length
}
const answer = answers[day]
// board state
class Tile {
letter = ''
state = LetterState.INITIAL
}
const board = $ref(
Array.from({ length: 6 }, () => {
return Array.from({ length: 5 }, () => new Tile())
})
)
let message = $ref('')
let allowInput = true
let currentRowIndex = $ref(0)
let shakeRowIndex = $ref(-1)
const currentRow = $computed(() => board[currentRowIndex])
// keep track of revealed letter state for the keyboard
const letterStates: Record<string, LetterState> = $ref({})
const onKeyup = (e: KeyboardEvent) => onKey(e.key)
window.addEventListener('keyup', onKeyup)
onUnmounted(() => {
window.removeEventListener('keyup', onKeyup)
})
function onKey(key: string) {
if (!allowInput) return
if (/^[a-z]$/.test(key)) {
fillTile(key)
} else if (key === 'Backspace') {
clearTile()
} else if (key === 'Enter') {
completeRow()
}
}
function fillTile(letter: string) {
for (const tile of currentRow) {
if (!tile.letter) {
tile.letter = letter
break
}
}
}
function clearTile() {
for (const tile of [...currentRow].reverse()) {
if (tile.letter) {
tile.letter = ''
break
}
}
}
function completeRow() {
if (currentRow.every((tile) => tile.letter)) {
const guess = currentRow.map((tile) => tile.letter).join('')
if (!allWords.includes(guess) && guess !== answer) {
shake()
showMessage(`Not in word list`)
return
}
const answerLetters: (string | null)[] = answer.split('')
// first pass: mark correct ones
currentRow.forEach((tile, i) => {
if (answerLetters[i] === tile.letter) {
tile.state = letterStates[tile.letter] = LetterState.CORRECT
answerLetters[i] = null
}
})
// second pass: mark the present
currentRow.forEach((tile) => {
if (!tile.state && answerLetters.includes(tile.letter)) {
tile.state = LetterState.PRESENT
answerLetters[answerLetters.indexOf(tile.letter)] = null
if (!letterStates[tile.letter]) {
letterStates[tile.letter] = LetterState.PRESENT
}
}
})
// 3rd pass: mark absent
currentRow.forEach((tile) => {
if (!tile.state) {
tile.state = LetterState.ABSENT
if (!letterStates[tile.letter]) {
letterStates[tile.letter] = LetterState.ABSENT
}
}
})
allowInput = false
if (currentRow.every((tile) => tile.state === LetterState.CORRECT)) {
// yay!
setTimeout(() => {
showMessage(
['Genius', 'Magnificent', 'Impressive', 'Splendid', 'Great', 'Phew'][
currentRowIndex
],
2000
)
}, 1600)
} else if (currentRowIndex < board.length - 1) {
// go the next row
currentRowIndex++
setTimeout(() => {
allowInput = true
}, 1600)
} else {
// game over :(
setTimeout(() => {
showMessage(answer.toUpperCase(), -1)
}, 1600)
}
} else {
shake()
showMessage('Not enough letters')
}
}
function showMessage(msg: string, time = 1000) {
message = msg
if (time > 0) {
setTimeout(() => {
message = ''
}, time)
}
}
function shake() {
shakeRowIndex = currentRowIndex
setTimeout(() => {
shakeRowIndex = -1
}, 1000)
}
</script>
<template>
<Transition>
<div class="message" v-if="message">{{ message }}</div>
</Transition>
<header>
<h1>VVORDLE</h1>
<a
id="source-link"
href="https://github.com/yyx990803/vue-wordle"
target="_blank"
>Source</a
>
</header>
<div id="board">
<div
class="row"
v-for="(row, index) in board"
:class="{ shake: shakeRowIndex === index }"
>
<div
v-for="(tile, index) in row"
:class="[
'tile',
tile.letter ? 'filled' : 'empty',
tile.state && 'revealed'
]"
>
<div class="front" :style="{ transitionDelay: `${index * 300}ms` }">
{{ tile.letter }}
</div>
<div
:class="['back', tile.state]"
:style="{ transitionDelay: `${index * 300}ms` }"
>
{{ tile.letter }}
</div>
</div>
</div>
</div>
<Keyboard @key="onKey" :letter-states="letterStates" />
</template>
<style scoped>
#board {
display: grid;
grid-template-rows: repeat(6, 1fr);
grid-gap: 5px;
padding: 10px;
box-sizing: border-box;
--height: min(420px, calc(var(--vh, 100vh) - 310px));
height: var(--height);
width: min(350px, calc(var(--height) / 6 * 5));
margin: 0px auto;
}
.message {
position: absolute;
left: 50%;
top: 80px;
color: #fff;
background-color: black;
padding: 16px 20px;
z-index: 2;
border-radius: 4px;
transform: translateX(-50%);
transition: opacity 0.3s ease-out;
font-weight: 600;
}
.message.v-leave-to {
opacity: 0;
}
.row {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-gap: 5px;
}
.tile {
width: 100%;
font-size: 2rem;
line-height: 2rem;
font-weight: bold;
vertical-align: middle;
text-transform: uppercase;
user-select: none;
position: relative;
}
@media (max-height: 680px) {
.tile {
font-size: 3vh;
}
}
.tile .front,
.tile .back {
box-sizing: border-box;
display: inline-flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: transform 0.6s;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.tile .front {
border: 2px solid #d3d6da;
}
.tile .back {
transform: rotateX(180deg);
}
.tile.filled .front {
border-color: #999;
}
.tile.filled {
animation: zoom 0.2s;
}
.tile:not(.empty) {
border: none;
}
.tile.revealed .front {
transform: rotateX(180deg);
}
.tile.revealed .back {
transform: rotateX(0deg);
}
@keyframes zoom {
0% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.shake {
animation: shake 0.5s;
}
@keyframes shake {
0% {
transform: translate(1px);
}
10% {
transform: translate(-2px);
}
20% {
transform: translate(2px);
}
30% {
transform: translate(-2px);
}
40% {
transform: translate(2px);
}
50% {
transform: translate(-2px);
}
60% {
transform: translate(2px);
}
70% {
transform: translate(-2px);
}
80% {
transform: translate(2px);
}
90% {
transform: translate(-2px);
}
100% {
transform: translate(1px);
}
}
</style>