636 lines
13 KiB
Go
636 lines
13 KiB
Go
package game
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
_ "image/png"
|
|
"log"
|
|
"os"
|
|
"path"
|
|
"runtime/pprof"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.rocketnine.space/tslocum/fibs"
|
|
"code.rocketnine.space/tslocum/kibodo"
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
|
"github.com/hajimehoshi/ebiten/v2/examples/resources/fonts"
|
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
|
"github.com/hajimehoshi/ebiten/v2/text"
|
|
"github.com/nfnt/resize"
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/font/opentype"
|
|
)
|
|
|
|
//go:embed assets
|
|
var assetsFS embed.FS
|
|
|
|
var debugExtra []byte
|
|
|
|
var debugGame *Game
|
|
|
|
var (
|
|
imgCheckerLight *ebiten.Image
|
|
imgCheckerDark *ebiten.Image
|
|
|
|
smallFont font.Face
|
|
mediumFont font.Face
|
|
monoFont font.Face
|
|
largeFont font.Face
|
|
)
|
|
|
|
var (
|
|
lightCheckerColor = color.RGBA{232, 211, 162, 255}
|
|
darkCheckerColor = color.RGBA{51, 0, 111, 255}
|
|
)
|
|
|
|
const defaultServerAddress = "fibs.com:4321"
|
|
|
|
const maxStatusWidthRatio = 0.5
|
|
|
|
const bufferCharacterWidth = 54
|
|
|
|
func init() {
|
|
loadAssets(0)
|
|
|
|
initializeFonts()
|
|
}
|
|
|
|
func loadAssets(width int) {
|
|
imgCheckerLight = loadAsset("assets/checker_white.png", width)
|
|
imgCheckerDark = loadAsset("assets/checker_white.png", width)
|
|
//imgCheckerDark = loadAsset("assets/checker_black.png", width)
|
|
}
|
|
|
|
func loadAsset(assetPath string, width int) *ebiten.Image {
|
|
f, err := assetsFS.Open(assetPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
img, _, err := image.Decode(f)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if width > 0 {
|
|
imgResized := resize.Resize(uint(width), 0, img, resize.Lanczos3)
|
|
return ebiten.NewImageFromImage(imgResized)
|
|
}
|
|
return ebiten.NewImageFromImage(img)
|
|
}
|
|
|
|
func initializeFonts() {
|
|
tt, err := opentype.Parse(fonts.MPlus1pRegular_ttf)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
const dpi = 72
|
|
smallFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
|
|
Size: smallFontSize,
|
|
DPI: dpi,
|
|
Hinting: font.HintingFull,
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
mediumFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
|
|
Size: mediumFontSize,
|
|
DPI: dpi,
|
|
Hinting: font.HintingFull,
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
largeFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
|
|
Size: largeFontSize,
|
|
DPI: dpi,
|
|
Hinting: font.HintingFull,
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tt, err = opentype.Parse(fonts.PressStart2P_ttf)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
monoFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
|
|
Size: monoFontSize,
|
|
DPI: dpi,
|
|
Hinting: font.HintingNone,
|
|
})
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
type Sprite struct {
|
|
image *ebiten.Image
|
|
w int
|
|
h int
|
|
x int
|
|
y int
|
|
toStart time.Time
|
|
toTime time.Duration
|
|
toX int
|
|
toY int
|
|
colorWhite bool
|
|
premove bool
|
|
}
|
|
|
|
type Sprites struct {
|
|
sprites []*Sprite
|
|
num int
|
|
}
|
|
|
|
var spinner = []byte(`-\|/`)
|
|
|
|
var viewBoard bool // View board or lobby
|
|
|
|
type Game struct {
|
|
screenW, screenH int
|
|
|
|
drawBuffer bytes.Buffer
|
|
lastDraw time.Time
|
|
|
|
spinnerIndex int
|
|
|
|
ServerAddress string
|
|
Username string
|
|
Password string
|
|
loggedIn bool
|
|
usernameConfirmed bool
|
|
|
|
Watch bool
|
|
TV bool
|
|
|
|
Client *fibs.Client
|
|
|
|
Board *board
|
|
|
|
lobby *lobby
|
|
pendingWho []*fibs.WhoInfo
|
|
|
|
runeBuffer []rune
|
|
inputBuffer string
|
|
|
|
Debug int
|
|
|
|
debugImg *ebiten.Image
|
|
|
|
keyboard *kibodo.Keyboard
|
|
keyboardInput []*kibodo.Input
|
|
shownKeyboard bool
|
|
|
|
statusBuffer *tabbedBuffers
|
|
gameBuffer *tabbedBuffers
|
|
|
|
cpuProfile *os.File
|
|
|
|
op *ebiten.DrawImageOptions
|
|
}
|
|
|
|
func NewGame() *Game {
|
|
g := &Game{
|
|
op: &ebiten.DrawImageOptions{
|
|
Filter: ebiten.FilterNearest,
|
|
},
|
|
Board: NewBoard(),
|
|
|
|
lobby: NewLobby(),
|
|
|
|
runeBuffer: make([]rune, 24),
|
|
|
|
keyboard: kibodo.NewKeyboard(),
|
|
|
|
statusBuffer: newTabbedBuffers(),
|
|
gameBuffer: newTabbedBuffers(),
|
|
|
|
debugImg: ebiten.NewImage(200, 200),
|
|
}
|
|
g.keyboard.SetKeys(kibodo.KeysQWERTY)
|
|
|
|
g.statusBuffer.acceptInput = true
|
|
|
|
fibs.StatusWriter = NewMessageHandler(g.statusBuffer.buffers[0])
|
|
fibs.GameWriter = NewMessageHandler(g.gameBuffer.buffers[0])
|
|
|
|
// TODO
|
|
go func() {
|
|
/*
|
|
time.Sleep(5 * time.Second)
|
|
g.lobby.offset += 10
|
|
g.lobby.bufferDirty = true
|
|
g.toggleProfiling()
|
|
g.lobby.drawBuffer()
|
|
g.toggleProfiling()
|
|
os.Exit(0)
|
|
*/
|
|
|
|
t := time.NewTicker(time.Second / 4)
|
|
for range t.C {
|
|
_ = g.update()
|
|
}
|
|
}()
|
|
|
|
debugGame = g // TODO
|
|
return g
|
|
}
|
|
|
|
func (g *Game) handleEvents() {
|
|
for e := range g.Client.Event {
|
|
switch event := e.(type) {
|
|
case *fibs.EventWho:
|
|
if viewBoard || g.lobby.refresh {
|
|
g.lobby.setWhoInfo(event.Who)
|
|
|
|
if g.lobby.refresh {
|
|
ebiten.ScheduleFrame()
|
|
g.lobby.refresh = false
|
|
}
|
|
} else {
|
|
g.pendingWho = event.Who
|
|
}
|
|
case *fibs.EventBoardState:
|
|
log.Println("EVENTBOARDSTATE START")
|
|
g.Board.SetState(event.S, event.V)
|
|
log.Println("EVENTBOARDSTATE FINISH")
|
|
case *fibs.EventMove:
|
|
log.Printf("EVENTMOVE START %d %d", event.From, event.To)
|
|
g.Board.movePiece(event.From, event.To)
|
|
log.Println("EVENTMOVE FINISH")
|
|
case *fibs.EventDraw:
|
|
log.Println("EVENTDRAW START")
|
|
g.Board.ProcessState()
|
|
log.Println("EVENTDRAW FINISH")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *Game) Connect() {
|
|
g.loggedIn = true
|
|
|
|
address := g.ServerAddress
|
|
if address == "" {
|
|
address = defaultServerAddress
|
|
}
|
|
g.Client = fibs.NewClient(address, g.Username, g.Password)
|
|
g.lobby.c = g.Client
|
|
g.Board.Client = g.Client
|
|
g.statusBuffer.client = g.Client
|
|
|
|
go g.handleEvents()
|
|
|
|
c := g.Client
|
|
|
|
if g.TV {
|
|
go func() {
|
|
time.Sleep(time.Second)
|
|
c.Out <- []byte("tv")
|
|
}()
|
|
} else if g.Watch {
|
|
go func() {
|
|
time.Sleep(time.Second)
|
|
c.Out <- []byte("watch")
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
err := c.Connect()
|
|
if err != nil {
|
|
fibs.StatusWriter.Write([]byte(err.Error()))
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Separate update function for all normal update logic, as Update may only be
|
|
// called when there is user input when vsync is disabled.
|
|
func (g *Game) update() error {
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) Update() error { // Called by ebiten only when input occurs
|
|
err := g.update()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if ebiten.IsWindowBeingClosed() {
|
|
g.Exit()
|
|
return nil
|
|
}
|
|
if g.pendingWho != nil && viewBoard {
|
|
g.lobby.setWhoInfo(g.pendingWho)
|
|
g.pendingWho = nil
|
|
}
|
|
|
|
if ebiten.IsKeyPressed(ebiten.KeyControl) && inpututil.IsKeyJustPressed(ebiten.KeyP) {
|
|
err = g.toggleProfiling()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
err = g.keyboard.Update()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update virtual keyboard: %s", err)
|
|
}
|
|
|
|
if !g.loggedIn {
|
|
f := func() {
|
|
var clearBuffer bool
|
|
defer func() {
|
|
if strings.ContainsRune(g.inputBuffer, '\n') {
|
|
g.inputBuffer = strings.Split(g.inputBuffer, "\n")[0]
|
|
clearBuffer = true
|
|
}
|
|
if !g.usernameConfirmed {
|
|
g.Username = g.inputBuffer
|
|
} else {
|
|
g.Password = g.inputBuffer
|
|
}
|
|
|
|
if clearBuffer {
|
|
g.inputBuffer = ""
|
|
|
|
if !g.usernameConfirmed {
|
|
g.usernameConfirmed = true
|
|
} else if g.Password != "" {
|
|
g.Connect()
|
|
}
|
|
}
|
|
}()
|
|
|
|
if !g.shownKeyboard {
|
|
g.keyboard.Show()
|
|
g.shownKeyboard = true
|
|
}
|
|
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) && len(g.inputBuffer) > 0 {
|
|
g.inputBuffer = g.inputBuffer[:len(g.inputBuffer)-1]
|
|
}
|
|
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
|
|
clearBuffer = true
|
|
}
|
|
|
|
g.runeBuffer = ebiten.AppendInputChars(g.runeBuffer[:0])
|
|
if len(g.runeBuffer) > 0 {
|
|
g.inputBuffer += string(g.runeBuffer)
|
|
}
|
|
|
|
// Process on-screen keyboard input.
|
|
g.keyboardInput = g.keyboard.AppendInput(g.keyboardInput[:0])
|
|
for _, input := range g.keyboardInput {
|
|
if input.Rune > 0 {
|
|
g.inputBuffer += string(input.Rune)
|
|
continue
|
|
}
|
|
if input.Key == ebiten.KeyBackspace {
|
|
if len(g.inputBuffer) > 0 {
|
|
g.inputBuffer = g.inputBuffer[:len(g.inputBuffer)-1]
|
|
}
|
|
} else if input.Key == ebiten.KeyEnter {
|
|
g.inputBuffer += "\n"
|
|
}
|
|
}
|
|
}
|
|
|
|
f()
|
|
}
|
|
|
|
if ebiten.IsKeyPressed(ebiten.KeyControl) && inpututil.IsKeyJustPressed(ebiten.KeyD) {
|
|
g.Debug++
|
|
if g.Debug == 3 {
|
|
g.Debug = 0
|
|
}
|
|
g.Board.debug = g.Debug
|
|
}
|
|
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
|
viewBoard = !viewBoard
|
|
}
|
|
|
|
if !viewBoard {
|
|
g.lobby.update()
|
|
} else {
|
|
g.Board.update()
|
|
|
|
g.statusBuffer.update()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) Draw(screen *ebiten.Image) {
|
|
frameTime := time.Second / 175
|
|
if time.Since(g.lastDraw) < frameTime {
|
|
//time.Sleep(time.Until(g.lastDraw.Add(frameTime)))
|
|
// TODO causes panics on WASM
|
|
// draw offscreen and cache, redraw cached image instead of sleeping?
|
|
}
|
|
g.lastDraw = time.Now()
|
|
|
|
screen.Fill(tableColor)
|
|
|
|
// Log in screen
|
|
if !g.loggedIn {
|
|
g.keyboard.Draw(screen)
|
|
|
|
const welcomeText = `Please enter your FIBS username and password.
|
|
If you do not have a FIBS account yet, visit
|
|
http://www.fibs.com/help.html#register`
|
|
debugBox := image.NewRGBA(image.Rect(0, 0, g.screenW, g.screenH))
|
|
debugImg := ebiten.NewImageFromImage(debugBox)
|
|
|
|
if !g.usernameConfirmed {
|
|
ebitenutil.DebugPrint(debugImg, welcomeText+fmt.Sprintf("\n\nUsername: %s", g.Username))
|
|
} else {
|
|
ebitenutil.DebugPrint(debugImg, welcomeText+fmt.Sprintf("\n\nPassword: %s", strings.Repeat("*", len(g.Password))))
|
|
}
|
|
|
|
g.resetImageOptions()
|
|
g.op.GeoM.Scale(2, 2)
|
|
screen.DrawImage(debugImg, g.op)
|
|
return
|
|
}
|
|
|
|
if !viewBoard {
|
|
// Lobby screen
|
|
g.lobby.draw(screen)
|
|
} else {
|
|
// Game board screen
|
|
g.gameBuffer.draw(screen)
|
|
g.statusBuffer.draw(screen)
|
|
g.Board.draw(screen)
|
|
}
|
|
|
|
if g.Debug > 0 {
|
|
g.drawBuffer.Reset()
|
|
|
|
g.drawBuffer.Write([]byte(fmt.Sprintf("FPS %c %0.0f", spinner[g.spinnerIndex], ebiten.CurrentFPS())))
|
|
|
|
g.spinnerIndex++
|
|
if g.spinnerIndex == 4 {
|
|
g.spinnerIndex = 0
|
|
}
|
|
|
|
scaleFactor := ebiten.DeviceScaleFactor()
|
|
if scaleFactor != 1.0 {
|
|
g.drawBuffer.WriteRune('\n')
|
|
g.drawBuffer.Write([]byte(fmt.Sprintf("SCA %0.1f", scaleFactor)))
|
|
}
|
|
|
|
if debugExtra != nil {
|
|
g.drawBuffer.WriteRune('\n')
|
|
g.drawBuffer.Write(debugExtra)
|
|
}
|
|
|
|
g.debugImg.Clear()
|
|
|
|
ebitenutil.DebugPrint(g.debugImg, g.drawBuffer.String())
|
|
|
|
g.resetImageOptions()
|
|
g.op.GeoM.Translate(3, 0)
|
|
g.op.GeoM.Scale(2, 2)
|
|
screen.DrawImage(g.debugImg, g.op)
|
|
}
|
|
}
|
|
|
|
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
|
|
s := ebiten.DeviceScaleFactor()
|
|
outsideWidth, outsideHeight = int(float64(outsideWidth)*s), int(float64(outsideHeight)*s)
|
|
if g.screenW == outsideWidth && g.screenH == outsideHeight {
|
|
return outsideWidth, outsideHeight
|
|
}
|
|
|
|
g.screenW, g.screenH = outsideWidth, outsideHeight
|
|
|
|
statusBufferWidth := text.BoundString(g.statusBuffer.chatFont, strings.Repeat("A", bufferCharacterWidth)).Dx()
|
|
if statusBufferWidth > int(float64(g.screenW)*maxStatusWidthRatio) {
|
|
statusBufferWidth = int(float64(g.screenW) * maxStatusWidthRatio)
|
|
}
|
|
|
|
showGameBufferLines := 8
|
|
gameBufferHeight := g.statusBuffer.chatFontSize * showGameBufferLines * 2
|
|
statusBufferHeight := g.screenH - gameBufferHeight
|
|
|
|
g.lobby.setRect(0, 0, g.screenW, g.screenH)
|
|
|
|
g.Board.setRect(0, 0, g.screenW-statusBufferWidth, g.screenH)
|
|
|
|
availableWidth := g.screenW - (g.Board.innerW + int(g.Board.horizontalBorderSize*2))
|
|
if availableWidth > statusBufferWidth {
|
|
statusBufferWidth = availableWidth
|
|
g.Board.setRect(0, 0, g.screenW-statusBufferWidth, g.screenH)
|
|
}
|
|
|
|
if g.Board.h > g.Board.w {
|
|
g.Board.setRect(0, 0, g.Board.w, g.Board.w)
|
|
}
|
|
|
|
if true || availableWidth >= 150 { // TODO allow chat window to be repositioned
|
|
g.statusBuffer.docked = true
|
|
g.statusBuffer.setRect(g.screenW-statusBufferWidth, g.screenH-(statusBufferHeight), statusBufferWidth, statusBufferHeight)
|
|
|
|
g.gameBuffer.docked = true
|
|
g.gameBuffer.setRect(g.screenW-statusBufferWidth, 0, statusBufferWidth, statusBufferHeight)
|
|
} else {
|
|
// Clamp buffer position.
|
|
bx, by := g.statusBuffer.x, g.statusBuffer.y
|
|
var bw, bh int
|
|
if g.statusBuffer.w == 0 && g.statusBuffer.h == 0 {
|
|
// Set initial buffer position.
|
|
bx = 0
|
|
by = g.screenH / 2
|
|
// Set initial buffer size.
|
|
bw = g.screenW
|
|
bh = g.screenH / 2
|
|
} else {
|
|
// Scale existing buffer size
|
|
bx, by = bx*(outsideWidth/g.screenW), by*(outsideHeight/g.screenH)
|
|
bw, bh = g.statusBuffer.w*(outsideWidth/g.screenW), g.statusBuffer.h*(outsideHeight/g.screenH)
|
|
if bw < 200 {
|
|
bw = 200
|
|
}
|
|
if bh < 100 {
|
|
bh = 100
|
|
}
|
|
}
|
|
padding := 7
|
|
if bx > g.screenW-padding {
|
|
bx = g.screenW - padding
|
|
}
|
|
if by > g.screenH-padding {
|
|
by = g.screenH - padding
|
|
}
|
|
|
|
g.statusBuffer.docked = false
|
|
g.statusBuffer.setRect(bx, by, bw, bh)
|
|
}
|
|
|
|
displayArea := 200
|
|
g.keyboard.SetRect(0, displayArea, g.screenW, g.screenH-displayArea)
|
|
return outsideWidth, outsideHeight
|
|
}
|
|
|
|
func (g *Game) resetImageOptions() {
|
|
g.op.GeoM.Reset()
|
|
}
|
|
|
|
func (g *Game) toggleProfiling() error {
|
|
if g.cpuProfile == nil {
|
|
log.Println("Profiling started...")
|
|
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
g.cpuProfile, err = os.Create(path.Join(homeDir, "cpu.prof")) // TODO add flag
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := pprof.StartCPUProfile(g.cpuProfile); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
pprof.StopCPUProfile()
|
|
g.cpuProfile.Close()
|
|
g.cpuProfile = nil
|
|
|
|
log.Println("Profiling stopped")
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) Exit() {
|
|
g.Board.drawFrame <- false
|
|
os.Exit(0)
|
|
}
|
|
|
|
type messageHandler struct {
|
|
t *textBuffer
|
|
}
|
|
|
|
func NewMessageHandler(t *textBuffer) *messageHandler {
|
|
return &messageHandler{
|
|
t: t,
|
|
}
|
|
}
|
|
|
|
func (m *messageHandler) Write(p []byte) (n int, err error) {
|
|
fmt.Print(string(p))
|
|
|
|
m.t.Write(p)
|
|
return len(p), nil
|
|
}
|