334 lines
6.7 KiB
Vue
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>
|