Add keyboard and mouse support

Keyboard and mouse controls will now work if you use the kbMouseSupport parameter in the config for Libretro cores. Be aware that capturing mouse and keyboard controls properly is only possible in fullscreen mode.

Note: In the case of DOSBox, a virtual filesystem handler is not yet implemented, thus each game state will be shared between all rooms (DOS game instances) of CloudRetro.
This commit is contained in:
Sergey Stepanov 2024-03-18 13:47:39 +03:00 committed by sergystepanov
parent af8569a605
commit 7ee98c1b03
38 changed files with 1561 additions and 543 deletions

View file

@ -12,8 +12,9 @@ type (
PlayerIndex int `json:"player_index"`
}
GameStartUserResponse struct {
RoomId string `json:"roomId"`
Av *AppVideoInfo `json:"av"`
RoomId string `json:"roomId"`
Av *AppVideoInfo `json:"av"`
KbMouse bool `json:"kb_mouse"`
}
IceServer struct {
Urls string `json:"urls,omitempty"`

View file

@ -33,8 +33,9 @@ type (
}
StartGameResponse struct {
Room
AV *AppVideoInfo `json:"av"`
Record bool
AV *AppVideoInfo `json:"av"`
Record bool `json:"record"`
KbMouse bool `json:"kb_mouse"`
}
RecordGameRequest[T Id] struct {
StatefulRoom[T]

View file

@ -185,11 +185,13 @@ emulator:
# - isGlAllowed (bool)
# - usesLibCo (bool)
# - hasMultitap (bool) -- (removed)
# - coreAspectRatio (bool) -- correct the aspect ratio on the client with the info from the core.
# - coreAspectRatio (bool) -- (deprecated) correct the aspect ratio on the client with the info from the core.
# - hid (map[int][]int)
# A list of device IDs to bind to the input ports.
# Can be seen in human readable form in the console when worker.debug is enabled.
# Some cores allow binding multiple devices to a single port (DosBox), but typically,
# you should bind just one device to one port.
# - kbMouseSupport (bool) -- (temp) a flag if the core needs the keyboard and mouse on the client
# - vfr (bool)
# (experimental)
# Enable variable frame rate only for cores that can't produce a constant frame rate.
@ -213,7 +215,6 @@ emulator:
mgba_audio_low_pass_filter: enabled
mgba_audio_low_pass_range: 40
pcsx:
coreAspectRatio: true
lib: pcsx_rearmed_libretro
roms: [ "cue", "chd" ]
# example of folder override
@ -227,7 +228,6 @@ emulator:
# https://docs.libretro.com/library/fbneo/
mame:
lib: fbneo_libretro
coreAspectRatio: true
roms: [ "zip" ]
nes:
lib: nestopia_libretro
@ -280,7 +280,7 @@ encoder:
# see: https://trac.ffmpeg.org/wiki/Encode/H.264
h264:
# Constant Rate Factor (CRF) 0-51 (default: 23)
crf: 26
crf: 23
# ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
preset: superfast
# baseline, main, high, high10, high422, high444

View file

@ -48,6 +48,7 @@ type LibretroCoreConfig struct {
Height int
Hid map[int][]int
IsGlAllowed bool
KbMouseSupport bool
Lib string
Options map[string]string
Options4rom map[string]map[string]string // <(^_^)>

View file

@ -37,6 +37,6 @@ func (u *User) SendWebrtcOffer(sdp string) { u.Notify(api.WebrtcOffer, sdp) }
func (u *User) SendWebrtcIceCandidate(candidate string) { u.Notify(api.WebrtcIce, candidate) }
// StartGame signals the user that everything is ready to start a game.
func (u *User) StartGame(av *api.AppVideoInfo) {
u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av})
func (u *User) StartGame(av *api.AppVideoInfo, kbMouse bool) {
u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av, KbMouse: kbMouse})
}

View file

@ -56,7 +56,7 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, launcher games.Launc
return
}
u.log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker")
u.StartGame(startGameResp.AV)
u.StartGame(startGameResp.AV, startGameResp.KbMouse)
// send back recording status
if conf.Recording.Enabled && rq.Record {

View file

@ -81,11 +81,15 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp
p.log.Debug().Msgf("Added [%s] track", audio.Codec().MimeType)
p.a = audio
// plug in the [data] channel (in and out)
if err = p.addDataChannel("data"); err != nil {
err = p.AddChannel("data", func(data []byte) {
if len(data) == 0 || p.OnMessage == nil {
return
}
p.OnMessage(data)
})
if err != nil {
return "", err
}
p.log.Debug().Msg("Added [data] chan")
p.conn.OnICEConnectionStateChange(p.handleICEState(func() { p.log.Info().Msg("Connected") }))
// Stream provider supposes to send offer
@ -221,6 +225,19 @@ func (p *Peer) AddCandidate(candidate string, decoder Decoder) error {
return nil
}
func (p *Peer) AddChannel(label string, onMessage func([]byte)) error {
ch, err := p.addDataChannel(label)
if err != nil {
return err
}
if label == "data" {
p.d = ch
}
ch.OnMessage(func(m webrtc.DataChannelMessage) { onMessage(m.Data) })
p.log.Debug().Msgf("Added [%v] chan", label)
return nil
}
func (p *Peer) Disconnect() {
if p.conn == nil {
return
@ -232,29 +249,19 @@ func (p *Peer) Disconnect() {
p.log.Debug().Msg("WebRTC stop")
}
// addDataChannel creates a new WebRTC data channel for user input.
// addDataChannel creates new WebRTC data channel.
// Default params -- ordered: true, negotiated: false.
func (p *Peer) addDataChannel(label string) error {
func (p *Peer) addDataChannel(label string) (*webrtc.DataChannel, error) {
ch, err := p.conn.CreateDataChannel(label, nil)
if err != nil {
return err
return nil, err
}
ch.OnOpen(func() {
p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).
Msg("Data channel [input] opened")
p.log.Debug().Uint16("id", *ch.ID()).Msgf("Data channel [%v] opened", ch.Label())
})
ch.OnError(p.logx)
ch.OnMessage(func(m webrtc.DataChannelMessage) {
if len(m.Data) == 0 {
return
}
if p.OnMessage != nil {
p.OnMessage(m.Data)
}
})
p.d = ch
ch.OnClose(func() { p.log.Debug().Msg("Data channel [input] has been closed") })
return nil
ch.OnClose(func() { p.log.Debug().Msgf("Data channel [%v] has been closed", ch.Label()) })
return ch, nil
}
func (p *Peer) logx(err error) { p.log.Error().Err(err) }

View file

@ -13,7 +13,8 @@ type App interface {
SetAudioCb(func(Audio))
SetVideoCb(func(Video))
SetDataCb(func([]byte))
SendControl(port int, data []byte)
Input(port int, device byte, data []byte)
KbMouseSupport() bool
}
type Audio struct {

View file

@ -15,6 +15,12 @@ type Manager struct {
log *logger.Logger
}
const (
RetroPad = libretro.RetroPad
Keyboard = libretro.Keyboard
Mouse = libretro.Mouse
)
type ModName string
const Libretro ModName = "libretro"

View file

@ -79,15 +79,16 @@ func (c *Caged) EnableCloudStorage(uid string, storage cloud.Storage) {
}
}
func (c *Caged) AspectEnabled() bool { return c.base.nano.Aspect }
func (c *Caged) AspectRatio() float32 { return c.base.AspectRatio() }
func (c *Caged) PixFormat() uint32 { return c.Emulator.PixFormat() }
func (c *Caged) Rotation() uint { return c.Emulator.Rotation() }
func (c *Caged) AudioSampleRate() int { return c.Emulator.AudioSampleRate() }
func (c *Caged) ViewportSize() (int, int) { return c.base.ViewportSize() }
func (c *Caged) Scale() float64 { return c.Emulator.Scale() }
func (c *Caged) SendControl(port int, data []byte) { c.base.Input(port, data) }
func (c *Caged) Start() { go c.Emulator.Start() }
func (c *Caged) SetSaveOnClose(v bool) { c.base.SaveOnClose = v }
func (c *Caged) SetSessionId(name string) { c.base.SetSessionId(name) }
func (c *Caged) Close() { c.Emulator.Close() }
func (c *Caged) AspectEnabled() bool { return c.base.nano.Aspect }
func (c *Caged) AspectRatio() float32 { return c.base.AspectRatio() }
func (c *Caged) PixFormat() uint32 { return c.Emulator.PixFormat() }
func (c *Caged) Rotation() uint { return c.Emulator.Rotation() }
func (c *Caged) AudioSampleRate() int { return c.Emulator.AudioSampleRate() }
func (c *Caged) ViewportSize() (int, int) { return c.base.ViewportSize() }
func (c *Caged) Scale() float64 { return c.Emulator.Scale() }
func (c *Caged) Input(p int, d byte, data []byte) { c.base.Input(p, d, data) }
func (c *Caged) KbMouseSupport() bool { return c.base.KbMouseSupport() }
func (c *Caged) Start() { go c.Emulator.Start() }
func (c *Caged) SetSaveOnClose(v bool) { c.base.SaveOnClose = v }
func (c *Caged) SetSessionId(name string) { c.base.SetSessionId(name) }
func (c *Caged) Close() { c.Emulator.Close() }

View file

@ -5,7 +5,6 @@ import (
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"time"
"unsafe"
@ -44,7 +43,7 @@ type Emulator interface {
// Close will be called when the game is done
Close()
// Input passes input to the emulator
Input(player int, data []byte)
Input(player int, device byte, data []byte)
// Scale returns set video scale factor
Scale() float64
}
@ -52,7 +51,6 @@ type Emulator interface {
type Frontend struct {
conf config.Emulator
done chan struct{}
input InputState
log *logger.Logger
nano *nanoarch.Nanoarch
onAudio func(app.Audio)
@ -70,21 +68,12 @@ type Frontend struct {
SaveOnClose bool
}
// InputState stores full controller state.
// It consists of:
// - uint16 button values
// - int16 analog stick values
type (
InputState [maxPort]State
State struct {
keys uint32
axes [dpadAxes]int32
}
)
type Device byte
const (
maxPort = 4
dpadAxes = 4
RetroPad = Device(nanoarch.RetroPad)
Keyboard = Device(nanoarch.Keyboard)
Mouse = Device(nanoarch.Mouse)
)
var (
@ -129,7 +118,6 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) {
f := &Frontend{
conf: conf,
done: make(chan struct{}),
input: NewGameSessionInput(),
log: log,
onAudio: noAudio,
onData: noData,
@ -162,6 +150,7 @@ func (f *Frontend) LoadCore(emu string) {
Options4rom: conf.Options4rom,
UsesLibCo: conf.UsesLibCo,
CoreAspectRatio: conf.CoreAspectRatio,
KbMouseSupport: conf.KbMouseSupport,
}
f.mu.Lock()
scale := 1.0
@ -227,8 +216,6 @@ func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) {
}
f.nano.WaitReady() // start only when nano is available
f.nano.OnKeyPress = f.input.isKeyPressed
f.nano.OnDpad = f.input.isDpadTouched
f.nano.OnVideo = f.handleVideo
f.nano.OnAudio = f.handleAudio
f.nano.OnDup = f.handleDup
@ -300,8 +287,8 @@ func (f *Frontend) Flipped() bool { return f.nano.IsGL() }
func (f *Frontend) FrameSize() (int, int) { return f.nano.BaseWidth(), f.nano.BaseHeight() }
func (f *Frontend) HasSave() bool { return os.Exists(f.HashPath()) }
func (f *Frontend) HashPath() string { return f.storage.GetSavePath() }
func (f *Frontend) Input(player int, data []byte) { f.input.setInput(player, data) }
func (f *Frontend) IsPortrait() bool { return f.nano.IsPortrait() }
func (f *Frontend) KbMouseSupport() bool { return f.nano.KbMouseSupport() }
func (f *Frontend) LoadGame(path string) error { return f.nano.LoadGame(path) }
func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C }
func (f *Frontend) RestoreGameState() error { return f.Load() }
@ -318,6 +305,17 @@ func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f
func (f *Frontend) ViewportRecalculate() { f.mu.Lock(); f.vw, f.vh = f.ViewportCalc(); f.mu.Unlock() }
func (f *Frontend) ViewportSize() (int, int) { return f.vw, f.vh }
func (f *Frontend) Input(port int, device byte, data []byte) {
switch Device(device) {
case RetroPad:
f.nano.InputRetropad(port, data)
case Keyboard:
f.nano.InputKeyboard(port, data)
case Mouse:
f.nano.InputMouse(port, data)
}
}
func (f *Frontend) ViewportCalc() (nw int, nh int) {
w, h := f.FrameSize()
nw, nh = w, h
@ -408,24 +406,3 @@ func (f *Frontend) autosave(periodSec int) {
}
}
}
func NewGameSessionInput() InputState { return [maxPort]State{} }
// setInput sets input state for some player in a game session.
func (s *InputState) setInput(player int, data []byte) {
atomic.StoreUint32(&s[player].keys, uint32(uint16(data[1])<<8+uint16(data[0])))
for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ {
axis := i<<1 + 2
atomic.StoreInt32(&s[player].axes[i], int32(data[axis+1])<<8+int32(data[axis]))
}
}
// isKeyPressed checks if some button is pressed by any player.
func (s *InputState) isKeyPressed(port uint, key int) int {
return int((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1)
}
// isDpadTouched checks if D-pad is used by any player.
func (s *InputState) isDpadTouched(port uint, axis uint) (shift int16) {
return int16(atomic.LoadInt32(&s[port].axes[axis]))
}

View file

@ -86,7 +86,6 @@ func EmulatorMock(room string, system string) *TestFrontend {
Path: os.TempDir(),
MainSave: room,
},
input: NewGameSessionInput(),
done: make(chan struct{}),
th: conf.Emulator.Threads,
log: l2,
@ -340,20 +339,6 @@ func TestStateConcurrency(t *testing.T) {
}
}
func TestConcurrentInput(t *testing.T) {
var wg sync.WaitGroup
state := NewGameSessionInput()
events := 1000
wg.Add(2 * events)
for range events {
player := rand.IntN(maxPort)
go func() { state.setInput(player, []byte{0, 1}); wg.Done() }()
go func() { state.isKeyPressed(uint(player), 100); wg.Done() }()
}
wg.Wait()
}
func TestStartStop(t *testing.T) {
f1 := DefaultFrontend("sushi", sushi.system, sushi.rom)
go f1.Start()

View file

@ -0,0 +1,150 @@
package nanoarch
import (
"encoding/binary"
"sync"
"sync/atomic"
)
//#include <stdint.h>
//#include "libretro.h"
import "C"
const (
Released C.int16_t = iota
Pressed
)
const RetrokLast = int(C.RETROK_LAST)
// InputState stores full controller state.
// It consists of:
// - uint16 button values
// - int16 analog stick values
type InputState [maxPort]RetroPadState
type (
RetroPadState struct {
keys uint32
axes [dpadAxes]int32
}
KeyboardState struct {
keys [RetrokLast]byte
mod uint16
mu sync.Mutex
}
MouseState struct {
dx, dy atomic.Int32
buttons atomic.Int32
}
)
type MouseBtnState int32
type Device byte
const (
RetroPad Device = iota
Keyboard
Mouse
)
const (
MouseMove = iota
MouseButton
)
const (
MouseLeft MouseBtnState = 1 << iota
MouseRight
MouseMiddle
)
const (
maxPort = 4
dpadAxes = 4
)
// Input sets input state for some player in a game session.
func (s *InputState) Input(port int, data []byte) {
atomic.StoreUint32(&s[port].keys, uint32(uint16(data[1])<<8+uint16(data[0])))
for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ {
axis := i<<1 + 2
atomic.StoreInt32(&s[port].axes[i], int32(data[axis+1])<<8+int32(data[axis]))
}
}
// IsKeyPressed checks if some button is pressed by any player.
func (s *InputState) IsKeyPressed(port uint, key int) C.int16_t {
return C.int16_t((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1)
}
// IsDpadTouched checks if D-pad is used by any player.
func (s *InputState) IsDpadTouched(port uint, axis uint) (shift C.int16_t) {
return C.int16_t(atomic.LoadInt32(&s[port].axes[axis]))
}
// SetKey sets keyboard state.
//
// 0 1 2 3 4 5 6
// [ KEY ] P MOD
//
// KEY contains Libretro code of the keyboard key (4 bytes).
// P contains 0 or 1 if the key is pressed (1 byte).
// MOD contains bitmask for Alt | Ctrl | Meta | Shift keys press state (2 bytes).
//
// Returns decoded state from the input bytes.
func (ks *KeyboardState) SetKey(data []byte) (pressed bool, key uint, mod uint16) {
if len(data) != 7 {
return
}
press := data[4]
pressed = press == 1
key = uint(binary.BigEndian.Uint32(data))
mod = binary.BigEndian.Uint16(data[5:])
ks.mu.Lock()
ks.keys[key] = press
ks.mod = mod
ks.mu.Unlock()
return
}
func (ks *KeyboardState) Pressed(key uint) C.int16_t {
ks.mu.Lock()
press := ks.keys[key]
ks.mu.Unlock()
if press == 1 {
return Pressed
}
return Released
}
// ShiftPos sets mouse relative position state.
//
// 0 1 2 3
// [dx] [dy]
//
// dx and dy are relative mouse coordinates
func (ms *MouseState) ShiftPos(data []byte) {
if len(data) != 4 {
return
}
dxy := binary.BigEndian.Uint32(data)
ms.dx.Add(int32(int16(dxy >> 16)))
ms.dy.Add(int32(int16(dxy)))
}
func (ms *MouseState) PopX() C.int16_t { return C.int16_t(ms.dx.Swap(0)) }
func (ms *MouseState) PopY() C.int16_t { return C.int16_t(ms.dy.Swap(0)) }
// SetButtons sets the state MouseBtnState of mouse buttons.
func (ms *MouseState) SetButtons(data byte) { ms.buttons.Store(int32(data)) }
func (ms *MouseState) Buttons() (l, r, m bool) {
mbs := MouseBtnState(ms.buttons.Load())
l = mbs&MouseLeft != 0
r = mbs&MouseRight != 0
m = mbs&MouseMiddle != 0
return
}

View file

@ -0,0 +1,93 @@
package nanoarch
import (
"encoding/binary"
"math/rand"
"sync"
"testing"
)
func TestConcurrentInput(t *testing.T) {
var wg sync.WaitGroup
state := InputState{}
events := 1000
wg.Add(2 * events)
for i := 0; i < events; i++ {
player := rand.Intn(maxPort)
go func() { state.Input(player, []byte{0, 1}); wg.Done() }()
go func() { state.IsKeyPressed(uint(player), 100); wg.Done() }()
}
wg.Wait()
}
func TestMousePos(t *testing.T) {
tests := []struct {
name string
dx int16
dy int16
rx int16
ry int16
b func(dx, dy int16) []byte
}{
{name: "normal", dx: -10123, dy: 5678, rx: -10123, ry: 5678, b: func(dx, dy int16) []byte {
data := []byte{0, 0, 0, 0}
binary.BigEndian.PutUint16(data, uint16(dx))
binary.BigEndian.PutUint16(data[2:], uint16(dy))
return data
}},
{name: "wrong endian", dx: -1234, dy: 5678, rx: 12027, ry: 11798, b: func(dx, dy int16) []byte {
data := []byte{0, 0, 0, 0}
binary.LittleEndian.PutUint16(data, uint16(dx))
binary.LittleEndian.PutUint16(data[2:], uint16(dy))
return data
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
data := test.b(test.dx, test.dy)
ms := MouseState{}
ms.ShiftPos(data)
x := int16(ms.PopX())
y := int16(ms.PopY())
if x != test.rx || y != test.ry {
t.Errorf("invalid state, %v = %v, %v = %v", test.rx, x, test.ry, y)
}
if ms.dx.Load() != 0 || ms.dy.Load() != 0 {
t.Errorf("coordinates weren't cleared")
}
})
}
}
func TestMouseButtons(t *testing.T) {
tests := []struct {
name string
data byte
l bool
r bool
m bool
}{
{name: "l+r+m+", data: 1 + 2 + 4, l: true, r: true, m: true},
{name: "l-r-m-", data: 0},
{name: "l-r+m-", data: 2, r: true},
{name: "l+r-m+", data: 1 + 4, l: true, m: true},
}
ms := MouseState{}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ms.SetButtons(test.data)
l, r, m := ms.Buttons()
if l != test.l || r != test.r || m != test.m {
t.Errorf("wrong button state: %v -> %v, %v, %v", test.data, l, r, m)
}
})
}
}

View file

@ -127,6 +127,10 @@ void bridge_clear_all_thread_waits_cb(void *data) {
*(retro_environment_t *)data = clear_all_thread_waits_cb;
}
void bridge_retro_keyboard_callback(void *cb, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers) {
(*(retro_keyboard_event_t *) cb)(down, keycode, character, keyModifiers);
}
bool core_environment_cgo(unsigned cmd, void *data) {
bool coreEnvironment(unsigned, void *);
return coreEnvironment(cmd, data);

View file

@ -28,13 +28,6 @@ import (
*/
import "C"
const lastKey = int(C.RETRO_DEVICE_ID_JOYPAD_R3)
const KeyPressed = 1
const KeyReleased = 0
const MaxPort int = 4
var (
RGBA5551 = PixFmt{C: 0, BPP: 2} // BIT_FORMAT_SHORT_5_5_5_1 has 5 bits R, 5 bits G, 5 bits B, 1 bit alpha
RGBA8888Rev = PixFmt{C: 1, BPP: 4} // BIT_FORMAT_INT_8_8_8_8_REV has 8 bits R, 8 bits G, 8 bits B, 8 bit alpha
@ -43,6 +36,12 @@ var (
type Nanoarch struct {
Handlers
keyboard KeyboardState
mouse MouseState
retropad InputState
keyboardCb *C.struct_retro_keyboard_callback
LastFrameTime int64
LibCo bool
meta Metadata
@ -77,8 +76,6 @@ type Nanoarch struct {
}
type Handlers struct {
OnDpad func(port uint, axis uint) (shift int16)
OnKeyPress func(port uint, key int) int
OnAudio func(ptr unsafe.Pointer, frames int)
OnVideo func(data []byte, delta int32, fi FrameInfo)
OnDup func()
@ -103,6 +100,7 @@ type Metadata struct {
Hacks []string
Hid map[int][]int
CoreAspectRatio bool
KbMouseSupport bool
}
type PixFmt struct {
@ -129,11 +127,9 @@ var Nan0 = Nanoarch{
Stopped: atomic.Bool{},
limiter: func(fn func()) { fn() },
Handlers: Handlers{
OnDpad: func(uint, uint) int16 { return 0 },
OnKeyPress: func(uint, int) int { return 0 },
OnAudio: func(unsafe.Pointer, int) {},
OnVideo: func([]byte, int32, FrameInfo) {},
OnDup: func() {},
OnAudio: func(unsafe.Pointer, int) {},
OnVideo: func([]byte, int32, FrameInfo) {},
OnDup: func() {},
},
}
@ -153,6 +149,7 @@ func (n *Nanoarch) AspectRatio() float32 { return float32(n.sys.av.g
func (n *Nanoarch) AudioSampleRate() int { return int(n.sys.av.timing.sample_rate) }
func (n *Nanoarch) VideoFramerate() int { return int(n.sys.av.timing.fps) }
func (n *Nanoarch) IsPortrait() bool { return 90 == n.Rot%180 }
func (n *Nanoarch) KbMouseSupport() bool { return n.meta.KbMouseSupport }
func (n *Nanoarch) BaseWidth() int { return int(n.sys.av.geometry.base_width) }
func (n *Nanoarch) BaseHeight() int { return int(n.sys.av.geometry.base_height) }
func (n *Nanoarch) WaitReady() { <-n.reserved }
@ -174,6 +171,12 @@ func (n *Nanoarch) CoreLoad(meta Metadata) {
// hacks
Nan0.hackSkipHwContextDestroy = meta.HasHack("skip_hw_context_destroy")
// reset controllers
n.retropad = InputState{}
n.keyboardCb = nil
n.keyboard = KeyboardState{}
n.mouse = MouseState{}
n.options = maps.Clone(meta.Options)
n.options4rom = meta.Options4rom
@ -312,7 +315,7 @@ func (n *Nanoarch) LoadGame(path string) error {
// set default controller types on all ports
// needed for nestopia
for i := range MaxPort {
for i := range maxPort {
C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(i), C.RETRO_DEVICE_JOYPAD)
}
@ -389,8 +392,34 @@ func (n *Nanoarch) Run() {
}
}
func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled }
func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() }
func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled }
func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() }
func (n *Nanoarch) InputRetropad(port int, data []byte) { n.retropad.Input(port, data) }
func (n *Nanoarch) InputKeyboard(_ int, data []byte) {
if n.keyboardCb == nil {
return
}
// we should preserve the state of pressed buttons for the input poll function (each retro_run)
// and explicitly call the retro_keyboard_callback function when a keyboard event happens
pressed, key, mod := n.keyboard.SetKey(data)
C.bridge_retro_keyboard_callback(unsafe.Pointer(n.keyboardCb), C.bool(pressed),
C.unsigned(key), C.uint32_t(0), C.uint16_t(mod))
}
func (n *Nanoarch) InputMouse(_ int, data []byte) {
if len(data) == 0 {
return
}
t := data[0]
state := data[1:]
switch t {
case MouseMove:
n.mouse.ShiftPos(state)
case MouseButton:
n.mouse.SetButtons(state[0])
}
}
func videoSetPixelFormat(format uint32) (C.bool, error) {
switch format {
@ -615,29 +644,55 @@ func coreInputPoll() {}
//export coreInputState
func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t {
if uint(port) >= uint(MaxPort) {
return KeyReleased
//Nan0.log.Debug().Msgf("%v %v %v %v", port, device, index, id)
// something like PCSX-ReArmed has 8 ports
if port >= maxPort {
return Released
}
if device == C.RETRO_DEVICE_ANALOG {
if index > C.RETRO_DEVICE_INDEX_ANALOG_RIGHT || id > C.RETRO_DEVICE_ID_ANALOG_Y {
return 0
switch device {
case C.RETRO_DEVICE_JOYPAD:
return Nan0.retropad.IsKeyPressed(uint(port), int(id))
case C.RETRO_DEVICE_ANALOG:
switch index {
case C.RETRO_DEVICE_INDEX_ANALOG_LEFT:
return Nan0.retropad.IsDpadTouched(uint(port), uint(index*2+id))
case C.RETRO_DEVICE_INDEX_ANALOG_RIGHT:
case C.RETRO_DEVICE_INDEX_ANALOG_BUTTON:
}
axis := index*2 + id
value := Nan0.Handlers.OnDpad(uint(port), uint(axis))
if value != 0 {
return (C.int16_t)(value)
case C.RETRO_DEVICE_KEYBOARD:
return Nan0.keyboard.Pressed(uint(id))
case C.RETRO_DEVICE_MOUSE:
switch id {
case C.RETRO_DEVICE_ID_MOUSE_X:
x := Nan0.mouse.PopX()
return x
case C.RETRO_DEVICE_ID_MOUSE_Y:
y := Nan0.mouse.PopY()
return y
case C.RETRO_DEVICE_ID_MOUSE_LEFT:
if l, _, _ := Nan0.mouse.Buttons(); l {
return Pressed
}
case C.RETRO_DEVICE_ID_MOUSE_RIGHT:
if _, r, _ := Nan0.mouse.Buttons(); r {
return Pressed
}
case C.RETRO_DEVICE_ID_MOUSE_WHEELUP:
case C.RETRO_DEVICE_ID_MOUSE_WHEELDOWN:
case C.RETRO_DEVICE_ID_MOUSE_MIDDLE:
if _, _, m := Nan0.mouse.Buttons(); m {
return Pressed
}
case C.RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELUP:
case C.RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELDOWN:
case C.RETRO_DEVICE_ID_MOUSE_BUTTON_4:
case C.RETRO_DEVICE_ID_MOUSE_BUTTON_5:
}
}
key := int(id)
if key > lastKey || index > 0 || device != C.RETRO_DEVICE_JOYPAD {
return KeyReleased
}
if Nan0.Handlers.OnKeyPress(uint(port), key) == KeyPressed {
return KeyPressed
}
return KeyReleased
return Released
}
//export coreAudioSample
@ -798,9 +853,20 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool {
}
cInfo.WriteString(fmt.Sprintf("%v: %v%s", cd[i].id, C.GoString(cd[i].desc), delim))
}
Nan0.log.Debug().Msgf("%v", cInfo.String())
//Nan0.log.Debug().Msgf("%v", cInfo.String())
}
return true
case C.RETRO_ENVIRONMENT_GET_INPUT_MAX_USERS:
*(*C.unsigned)(data) = C.unsigned(4)
Nan0.log.Debug().Msgf("Set max users: %v", 4)
return true
case C.RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK:
Nan0.log.Debug().Msgf("Keyboard event callback was set")
Nan0.keyboardCb = (*C.struct_retro_keyboard_callback)(data)
return true
case C.RETRO_ENVIRONMENT_GET_INPUT_BITMASKS:
Nan0.log.Debug().Msgf("Set input bitmasks: false")
return false
case C.RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB:
C.bridge_clear_all_thread_waits_cb(data)
return true
@ -906,9 +972,7 @@ func geometryChange(geom C.struct_retro_game_geometry) {
if Nan0.OnSystemAvInfo != nil {
Nan0.log.Debug().Msgf(">>> geometry change %v -> %v", old, geom)
if Nan0.Aspect {
go Nan0.OnSystemAvInfo()
}
go Nan0.OnSystemAvInfo()
}
})
}

View file

@ -23,6 +23,7 @@ void bridge_retro_set_input_poll(void *f, void *callback);
void bridge_retro_set_input_state(void *f, void *callback);
void bridge_retro_set_video_refresh(void *f, void *callback);
void bridge_clear_all_thread_waits_cb(void *f);
void bridge_retro_keyboard_callback(void *f, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers);
bool core_environment_cgo(unsigned cmd, void *data);
int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, unsigned id);

View file

@ -126,12 +126,14 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
}
}
data, err := api.Wrap(api.Out{T: uint8(api.AppVideoChange), Payload: api.AppVideoInfo{
W: m.VideoW,
H: m.VideoH,
A: app.AspectRatio(),
S: int(app.Scale()),
}})
data, err := api.Wrap(api.Out{
T: uint8(api.AppVideoChange),
Payload: api.AppVideoInfo{
W: m.VideoW,
H: m.VideoH,
A: app.AspectRatio(),
S: int(app.Scale()),
}})
if err != nil {
c.log.Error().Err(err).Msgf("wrap")
}
@ -159,6 +161,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
w.router.SetRoom(nil)
return api.EmptyPacket
}
if app.Flipped() {
m.SetVideoFlip(true)
}
@ -170,11 +173,23 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
}
c.log.Debug().Msg("Start session input poll")
room.WithWebRTC(user.Session).OnMessage = func(data []byte) { r.App().SendControl(user.Index, data) }
needsKbMouse := r.App().KbMouseSupport()
s := room.WithWebRTC(user.Session)
s.OnMessage = func(data []byte) { r.App().Input(user.Index, byte(caged.RetroPad), data) }
if needsKbMouse {
_ = s.AddChannel("keyboard", func(data []byte) { r.App().Input(user.Index, byte(caged.Keyboard), data) })
_ = s.AddChannel("mouse", func(data []byte) { r.App().Input(user.Index, byte(caged.Mouse), data) })
}
c.RegisterRoom(r.Id())
response := api.StartGameResponse{Room: api.Room{Rid: r.Id()}, Record: w.conf.Recording.Enabled}
response := api.StartGameResponse{
Room: api.Room{Rid: r.Id()},
Record: w.conf.Recording.Enabled,
KbMouse: needsKbMouse,
}
if r.App().AspectEnabled() {
ww, hh := r.App().ViewportSize()
response.AV = &api.AppVideoInfo{W: ww, H: hh, A: r.App().AspectRatio(), S: int(r.App().Scale())}

View file

@ -17,10 +17,6 @@ html {
body {
background-image: url('/img/background.jpg');
background-repeat: repeat;
align-items: center;
display: flex;
justify-content: center;
}
#gamebody {
@ -96,7 +92,7 @@ body {
color: #979797;
font-size: 8px;
top: 269px;
left: 30px;
left: 101px;
position: absolute;
user-select: none;
@ -404,7 +400,7 @@ body {
object-fit: contain;
width: inherit;
height: inherit;
background-color: #222222;
background-color: #101010;
}
#menu-screen {
@ -487,67 +483,10 @@ body {
.menu-item__info {
color: white;
opacity: .55;
font-size: 30%;
position: absolute;
left: 15px;
}
.text-move {
animation: horizontally 4s linear infinite alternate;
}
@-moz-keyframes horizontally {
0% {
transform: translateX(0%);
}
25% {
transform: translateX(-20%);
}
50% {
transform: translateX(0%);
}
75% {
transform: translateX(20%);
}
100% {
transform: translateX(0%);
}
}
@-webkit-keyframes horizontally {
0% {
transform: translateX(0%);
}
25% {
transform: translateX(-20%);
}
50% {
transform: translateX(0%);
}
75% {
transform: translateX(20%);
}
100% {
transform: translateX(0%);
}
}
@keyframes horizontally {
0% {
transform: translateX(0%);
}
25% {
transform: translateX(-20%);
}
50% {
transform: translateX(0%);
}
75% {
transform: translateX(20%);
}
100% {
transform: translateX(0%);
}
text-align: center;
padding-top: 3px;
}
#noti-box {
@ -635,50 +574,6 @@ body {
touch-action: manipulation;
}
#stats-overlay {
position: absolute;
z-index: 200;
backface-visibility: hidden;
cursor: default;
display: flex;
flex-direction: column;
justify-content: space-around;
top: 1.1em;
right: 1.1em;
color: #fff;
background: #000;
opacity: .465;
font-size: 1.45vh;
font-family: monospace;
min-width: 3em;
padding-right: .2em;
visibility: hidden;
}
#stats-overlay > div {
display: flex;
flex-flow: wrap;
justify-content: space-between;
align-items: center;
}
#stats-overlay > div > div {
display: inline-block;
font-weight: 500;
}
#stats-overlay .graph {
width: 100%;
/* artifacts with pixelated option */
/*image-rendering: pixelated;*/
image-rendering: optimizeSpeed;
}
.dpad-toggle-label {
position: absolute;
display: inline-block;
@ -735,10 +630,6 @@ input:checked + .dpad-toggle-slider:before {
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
}
.source #v {
cursor: default;
}
.source a {
color: #dddddd;
}

View file

@ -228,12 +228,26 @@
color: #7e7e7e;
}
.app-button.fs {
position: relative;
top: 0;
left: 0;
}
#mirror-stream {
image-rendering: pixelated;
width: 100%;
height: 100%;
}
#screen {
display: flex;
align-items: center;
/*align-items: center;*/
justify-content: center;
min-width: 0 !important;
min-height: 0 !important;
position: absolute;
/* popups under the screen fix */
z-index: -1;
@ -243,8 +257,104 @@
top: 23px;
left: 150px;
overflow: hidden;
background-color: #333;
background-color: #000000;
border-radius: 5px 5px 5px 5px;
box-shadow: 0 0 2px 2px rgba(25, 25, 25, 1);
}
.screen__footer {
position: absolute;
bottom: 0;
display: flex;
flex-direction: row;
border-top: 1px solid #1b1b1b;
width: calc(100% - .6rem);
justify-content: space-between;
background-color: #00000022;
height: 13px;
font-size: .6rem;
color: #ffffff;
opacity: .3;
padding: 0 .2em;
cursor: default;
}
.hover:hover {
opacity: .567;
}
.with-footer {
height: calc(100% - 14px);
}
.kbm-button {
top: 265px;
left: 542px;
text-align: center;
font-size: 70%;
opacity: .5;
filter: contrast(.3);
}
.kbm-button-fs {
width: 1em;
text-align: center;
font-size: 110%;
/*color: #ffffff;*/
/*opacity: .5;*/
filter: contrast(.3);
}
.no-pointer {
cursor: none;
}
#stats-overlay {
cursor: default;
display: flex;
flex-direction: row;
justify-content: end;
color: #fff;
background: #000;
/*opacity: .3;*/
font-size: 10px;
font-family: monospace;
min-width: 18em;
gap: 5px;
visibility: hidden;
}
#stats-overlay > div {
display: flex;
flex-flow: wrap;
justify-content: space-between;
align-items: center;
}
#stats-overlay > div > div {
display: inline-block;
font-weight: 500;
}
#stats-overlay .graph {
width: 100%;
/* artifacts with pixelated option */
/*image-rendering: pixelated;*/
image-rendering: optimizeSpeed;
}
.stats-bitrate {
min-width: 3.3rem;
}

View file

@ -32,21 +32,27 @@
</div>
<div id="screen">
<div id="stats-overlay"></div>
<video id="stream" class="game-screen" hidden muted preload="none"></video>
<canvas id="mirror-stream" class="game-screen" hidden></canvas>
<div id="menu-screen">
<div id="menu-container"></div>
<div id="menu-item-choice" class="hidden"></div>
</div>
<div class="screen__footer hover hidden">
<div>cloudretro</div>
<div class="app-button fs kbm-button-fs hidden" id="kbm2"
title="Switch from keyboard/mouse to retropad">🎮+⌨️+🖱️
</div>
<div id="stats-overlay"></div>
</div>
</div>
<div id="servers"></div>
<div id="settings"></div>
<div id="guide-txt">
<b>Arrows</b> (move), <b>ZXCVAS;'./</b> (game ABXYL1-L3R1-R3), <b>1/2</b> (1st/2nd player),
<b>Shift/Enter/K/L</b> (select/start/save/load), <b>F</b> (fullscreen), <b>share</b> (copy the link to the
clipboard)
<b>Arrows</b> (move), <b>ZXCVAS;'./</b> (game ABXYL1-L3R1-R3),
<b>Shift/Enter/K/L</b> (select/start/save/load), <b>F</b> (fullscreen), <b>share</b> (copy the link)
</div>
<div id="btn-join" class="btn big" data-key="join"></div>
<div id="slider-playeridx" class="slidecontainer">
@ -55,6 +61,9 @@
</div>
<div id="btn-quit" class="btn big" data-key="quit"></div>
<div class="app-button kbm-button hidden" id="kbm" title="Switch from keyboard/mouse to retropad">
C🎮+K⌨+M🖱</div>
<div id="controls-right">
<div id="btn-load" class="btn big hidden" data-key="load"></div>
<div id="btn-save" class="btn big hidden" data-key="save"></div>
@ -91,10 +100,9 @@
<div id="btn-rec" class="btn" data-key="rec"></div>
{{end}}
</div>
<div class="source">
<span id="v">69ff8ae</span>
<div class="source"><span>Cloudretro (ɔ) 2024</span>
<a rel="noopener noreferrer" target="_blank" href="https://github.com/giongto35/cloud-game">
Source code on GitHub
<span id="v">69ff8ae</span>
</a>
</div>

View file

@ -19,6 +19,277 @@ const endpoints = {
APP_VIDEO_CHANGE: 150,
}
let transport = {
send: (packet) => {
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
},
keyboard: (packet) => {
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
},
mouse: (packet) => {
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
}
}
const packet = (type, payload, id) => {
const packet = {t: type}
if (id !== undefined) packet.id = id
if (payload !== undefined) packet.p = payload
transport.send(packet)
}
const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b))
const keyboardPress = (() => {
// 0 1 2 3 4 5 6
// [CODE ] P MOD
const buffer = new ArrayBuffer(7)
const dv = new DataView(buffer)
return (pressed = false, e) => {
if (e.repeat) return // skip pressed key events
const key = libretro.mod
let code = libretro.map('', e.code)
let shift = e.shiftKey
// a special Esc for &$&!& Firefox
if (shift && code === 96) {
code = 27
shift = false
}
const mod = 0
| (e.altKey && key.ALT)
| (e.ctrlKey && key.CTRL)
| (e.metaKey && key.META)
| (shift && key.SHIFT)
| (e.getModifierState('NumLock') && key.NUMLOCK)
| (e.getModifierState('CapsLock') && key.CAPSLOCK)
| (e.getModifierState('ScrollLock') && key.SCROLLOCK)
dv.setUint32(0, code)
dv.setUint8(4, +pressed)
dv.setUint16(5, mod)
transport.keyboard(buffer)
}
})()
const mouse = {
MOVEMENT: 0,
BUTTONS: 1
}
const mouseMove = (() => {
// 0 1 2 3 4
// T DX DY
const buffer = new ArrayBuffer(5)
const dv = new DataView(buffer)
return (dx = 0, dy = 0) => {
dv.setUint8(0, mouse.MOVEMENT)
dv.setInt16(1, dx)
dv.setInt16(3, dy)
transport.mouse(buffer)
}
})()
const mousePress = (() => {
// 0 1
// T B
const buffer = new ArrayBuffer(2)
const dv = new DataView(buffer)
// 0: Main button pressed, usually the left button or the un-initialized state
// 1: Auxiliary button pressed, usually the wheel button or the middle button (if present)
// 2: Secondary button pressed, usually the right button
// 3: Fourth button, typically the Browser Back button
// 4: Fifth button, typically the Browser Forward button
const b2r = [1, 4, 2, 0, 0] // browser mouse button to retro button
// assumed that only one button pressed / released
return (button = 0, pressed = false) => {
dv.setUint8(0, mouse.BUTTONS)
dv.setUint8(1, pressed ? b2r[button] : 0)
transport.mouse(buffer)
}
})()
const libretro = function () {// RETRO_KEYBOARD
const retro = {
'': 0,
'Unidentified': 0,
'Unknown': 0, // ???
'First': 0, // ???
'Backspace': 8,
'Tab': 9,
'Clear': 12,
'Enter': 13, 'Return': 13,
'Pause': 19,
'Escape': 27,
'Space': 32,
'Exclaim': 33,
'Quotedbl': 34,
'Hash': 35,
'Dollar': 36,
'Ampersand': 38,
'Quote': 39,
'Leftparen': 40, '(': 40,
'Rightparen': 41, ')': 41,
'Asterisk': 42,
'Plus': 43,
'Comma': 44,
'Minus': 45,
'Period': 46,
'Slash': 47,
'Digit0': 48,
'Digit1': 49,
'Digit2': 50,
'Digit3': 51,
'Digit4': 52,
'Digit5': 53,
'Digit6': 54,
'Digit7': 55,
'Digit8': 56,
'Digit9': 57,
'Colon': 58, ':': 58,
'Semicolon': 59, ';': 59,
'Less': 60, '<': 60,
'Equal': 61, '=': 61,
'Greater': 62, '>': 62,
'Question': 63, '?': 63,
// RETROK_AT = 64,
'BracketLeft': 91, '[': 91,
'Backslash': 92, '\\': 92,
'BracketRight': 93, ']': 93,
// RETROK_CARET = 94,
// RETROK_UNDERSCORE = 95,
'Backquote': 96, '`': 96,
'KeyA': 97,
'KeyB': 98,
'KeyC': 99,
'KeyD': 100,
'KeyE': 101,
'KeyF': 102,
'KeyG': 103,
'KeyH': 104,
'KeyI': 105,
'KeyJ': 106,
'KeyK': 107,
'KeyL': 108,
'KeyM': 109,
'KeyN': 110,
'KeyO': 111,
'KeyP': 112,
'KeyQ': 113,
'KeyR': 114,
'KeyS': 115,
'KeyT': 116,
'KeyU': 117,
'KeyV': 118,
'KeyW': 119,
'KeyX': 120,
'KeyY': 121,
'KeyZ': 122,
'{': 123,
'|': 124,
'}': 125,
'Tilde': 126, '~': 126,
'Delete': 127,
'Numpad0': 256,
'Numpad1': 257,
'Numpad2': 258,
'Numpad3': 259,
'Numpad4': 260,
'Numpad5': 261,
'Numpad6': 262,
'Numpad7': 263,
'Numpad8': 264,
'Numpad9': 265,
'NumpadDecimal': 266,
'NumpadDivide': 267,
'NumpadMultiply': 268,
'NumpadSubtract': 269,
'NumpadAdd': 270,
'NumpadEnter': 271,
'NumpadEqual': 272,
'ArrowUp': 273,
'ArrowDown': 274,
'ArrowRight': 275,
'ArrowLeft': 276,
'Insert': 277,
'Home': 278,
'End': 279,
'PageUp': 280,
'PageDown': 281,
'F1': 282,
'F2': 283,
'F3': 284,
'F4': 285,
'F5': 286,
'F6': 287,
'F7': 288,
'F8': 289,
'F9': 290,
'F10': 291,
'F11': 292,
'F12': 293,
'F13': 294,
'F14': 295,
'F15': 296,
'NumLock': 300,
'CapsLock': 301,
'ScrollLock': 302,
'ShiftRight': 303,
'ShiftLeft': 304,
'ControlRight': 305,
'ControlLeft': 306,
'AltRight': 307,
'AltLeft': 308,
'MetaRight': 309,
'MetaLeft': 310,
// RETROK_LSUPER = 311,
// RETROK_RSUPER = 312,
// RETROK_MODE = 313,
// RETROK_COMPOSE = 314,
// RETROK_HELP = 315,
// RETROK_PRINT = 316,
// RETROK_SYSREQ = 317,
// RETROK_BREAK = 318,
// RETROK_MENU = 319,
'Power': 320,
// RETROK_EURO = 321,
// RETROK_UNDO = 322,
// RETROK_OEM_102 = 323,
}
const retroMod = {
NONE: 0x0000,
SHIFT: 0x01,
CTRL: 0x02,
ALT: 0x04,
META: 0x08,
NUMLOCK: 0x10,
CAPSLOCK: 0x20,
SCROLLOCK: 0x40,
}
const _map = (key = '', code = '') => {
return retro[code] || retro[key] || 0
}
return {
map: _map,
mod: retroMod,
}
}()
/**
* Server API.
*
@ -38,6 +309,15 @@ export const api = {
getWorkerList: () => packet(endpoints.GET_WORKER_LIST),
},
game: {
input: {
keyboard: {
press: keyboardPress,
},
mouse: {
move: mouseMove,
press: mousePress,
}
},
load: () => packet(endpoints.GAME_LOAD),
save: () => packet(endpoints.GAME_SAVE),
setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i),
@ -53,18 +333,3 @@ export const api = {
quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}),
}
}
let transport = {
send: (packet) => {
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
}
}
const packet = (type, payload, id) => {
const packet = {t: type};
if (id !== undefined) packet.id = id;
if (payload !== undefined) packet.p = payload;
transport.send(packet);
}
const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b))

View file

@ -1,19 +1,13 @@
import {log} from 'log';
import {opts, settings} from 'settings';
settings.init();
log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT);
import {api} from 'api';
import {
pub,
sub,
APP_VIDEO_CHANGED,
AXIS_CHANGED,
CONTROLLER_UPDATED,
DPAD_TOGGLE,
FULLSCREEN_CHANGE,
GAME_ERROR_NO_FREE_SLOTS,
GAME_LOADED,
GAME_PLAYER_IDX,
GAME_PLAYER_IDX_SET,
GAME_ROOM_AVAILABLE,
@ -21,12 +15,19 @@ import {
GAMEPAD_CONNECTED,
GAMEPAD_DISCONNECTED,
HELP_OVERLAY_TOGGLED,
KB_MOUSE_FLAG,
KEY_PRESSED,
KEY_RELEASED,
KEYBOARD_KEY_DOWN,
KEYBOARD_KEY_UP,
LATENCY_CHECK_REQUESTED,
MESSAGE,
MOUSE_MOVED,
MOUSE_PRESSED,
POINTER_LOCK_CHANGE,
RECORDING_STATUS_CHANGED,
RECORDING_TOGGLED,
REFRESH_INPUT,
SETTINGS_CHANGED,
WEBRTC_CONNECTION_CLOSED,
WEBRTC_CONNECTION_READY,
@ -37,9 +38,11 @@ import {
WEBRTC_SDP_ANSWER,
WEBRTC_SDP_OFFER,
WORKER_LIST_FETCHED,
pub,
sub,
} from 'event';
import {gui} from 'gui';
import {keyboard, KEY, joystick, retropad, touch} from 'input';
import {input, KEY} from 'input';
import {socket, webrtc} from 'network';
import {debounce} from 'utils';
@ -53,7 +56,10 @@ import {stats} from './stats.js?v=3';
import {stream} from './stream.js?v=3';
import {workerManager} from "./workerManager.js?v=3";
// application state
settings.init();
log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT);
// application display state
let state;
let lastState;
@ -102,18 +108,7 @@ const setState = (newState = app.state.eden) => {
}
};
const onGameRoomAvailable = () => {
// room is ready
};
const onConnectionReady = () => {
// start a game right away or show the menu
if (room.getId()) {
startGame();
} else {
state.menuReady();
}
};
const onConnectionReady = () => room.id ? startGame() : state.menuReady()
const onLatencyCheck = async (data) => {
message.show('Connecting to fastest server...');
@ -169,23 +164,21 @@ const startGame = () => {
setState(app.state.game);
stream.play()
screen.toggle(stream)
api.game.start(
gameList.selected,
room.getId(),
room.id,
recording.isActive(),
recording.getUser(),
+playerIndex.value - 1,
);
)
// clear menu screen
retropad.poll.disable();
screen.toggle(stream);
gameList.disable()
input.retropad.toggle(false)
gui.show(keyButtons[KEY.SAVE]);
gui.show(keyButtons[KEY.LOAD]);
// end clear
retropad.poll.enable();
input.retropad.toggle(true)
};
const saveGame = debounce(() => api.game.save(), 1000);
@ -204,16 +197,14 @@ const onMessage = (m) => {
pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload});
break;
case api.endpoint.GAME_START:
if (payload.av) {
pub(APP_VIDEO_CHANGED, payload.av)
}
payload.av && pub(APP_VIDEO_CHANGED, payload.av)
payload.kb_mouse && pub(KB_MOUSE_FLAG)
pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId});
break;
case api.endpoint.GAME_SAVE:
pub(GAME_SAVED);
break;
case api.endpoint.GAME_LOAD:
pub(GAME_LOADED);
break;
case api.endpoint.GAME_SET_PLAYER_INDEX:
pub(GAME_PLAYER_IDX_SET, payload);
@ -252,7 +243,7 @@ const onKeyPress = (data) => {
if (KEY.HELP === data.key) helpScreen.show(true, event);
}
state.keyPress(data.key);
state.keyPress(data.key, data.code)
};
// pre-state key release handler
@ -279,7 +270,7 @@ const onKeyRelease = data => {
// change app state if settings
if (KEY.SETTINGS === data.key) setState(app.state.settings);
state.keyRelease(data.key);
state.keyRelease(data.key, data.code);
};
const updatePlayerIndex = (idx, not_game = false) => {
@ -301,8 +292,10 @@ const onAxisChanged = (data) => {
state.axisChanged(data.id, data.value);
};
const handleToggle = () => {
const handleToggle = (force = false) => {
const toggle = document.getElementById('dpad-toggle');
force && toggle.setAttribute('checked', '')
toggle.checked = !toggle.checked;
pub(DPAD_TOGGLE, {checked: toggle.checked});
};
@ -402,10 +395,13 @@ const app = {
game: {
..._default,
name: 'game',
axisChanged: (id, value) => retropad.setAxisChanged(id, value),
keyPress: key => retropad.setKeyState(key, true),
axisChanged: (id, value) => input.retropad.setAxisChanged(id, value),
keyboardInput: (pressed, e) => api.game.input.keyboard.press(pressed, e),
mouseMove: (e) => api.game.input.mouse.move(e.dx, e.dy),
mousePress: (e) => api.game.input.mouse.press(e.b, e.p),
keyPress: (key) => input.retropad.setKeyState(key, true),
keyRelease: function (key) {
retropad.setKeyState(key, false);
input.retropad.setKeyState(key, false);
switch (key) {
case KEY.JOIN: // or SHARE
@ -436,8 +432,8 @@ const app = {
updatePlayerIndex(3);
break;
case KEY.QUIT:
retropad.poll.disable();
api.game.quit(room.getId());
input.retropad.toggle(false)
api.game.quit(room.id)
room.reset();
window.location = window.location.pathname;
break;
@ -453,10 +449,37 @@ const app = {
}
};
// switch keyboard+mouse / retropad
const kbmEl = document.getElementById('kbm')
const kbmEl2 = document.getElementById('kbm2')
let kbmSkip = false
const kbmCb = () => {
input.kbm = kbmSkip
kbmSkip = !kbmSkip
pub(REFRESH_INPUT)
}
gui.multiToggle([kbmEl, kbmEl2], {
list: [
{caption: '⌨️+🖱️', cb: kbmCb},
{caption: ' 🎮 ', cb: kbmCb}
]
})
sub(KB_MOUSE_FLAG, () => {
gui.show(kbmEl, kbmEl2)
handleToggle(true)
message.show('Keyboard and mouse work in fullscreen')
})
// Browser lock API
document.onpointerlockchange = () => pub(POINTER_LOCK_CHANGE, document.pointerLockElement)
document.onfullscreenchange = () => pub(FULLSCREEN_CHANGE, document.fullscreenElement)
// subscriptions
sub(MESSAGE, onMessage);
sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2);
sub(GAME_ROOM_AVAILABLE, async () => {
stream.play()
}, 2)
sub(GAME_SAVED, () => message.show('Saved'));
sub(GAME_PLAYER_IDX, data => {
updatePlayerIndex(+data.index, state !== app.state.game);
@ -479,14 +502,25 @@ sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)
sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates());
sub(WEBRTC_CONNECTION_READY, onConnectionReady);
sub(WEBRTC_CONNECTION_CLOSED, () => {
retropad.poll.disable();
input.retropad.toggle(false)
webrtc.stop();
});
sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected'));
sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected'));
// keyboard handler in the Screen Lock mode
sub(KEYBOARD_KEY_DOWN, (v) => state.keyboardInput?.(true, v))
sub(KEYBOARD_KEY_UP, (v) => state.keyboardInput?.(false, v))
// mouse handler in the Screen Lock mode
sub(MOUSE_MOVED, (e) => state.mouseMove?.(e))
sub(MOUSE_PRESSED, (e) => state.mousePress?.(e))
// general keyboard handler
sub(KEY_PRESSED, onKeyPress);
sub(KEY_RELEASED, onKeyRelease);
sub(SETTINGS_CHANGED, () => message.show('Settings have been updated'));
sub(AXIS_CHANGED, onAxisChanged);
sub(CONTROLLER_UPDATED, data => webrtc.input(data));
@ -496,18 +530,13 @@ sub(RECORDING_STATUS_CHANGED, handleRecordingStatus);
sub(SETTINGS_CHANGED, () => {
const s = settings.get();
log.level = s[opts.LOG_LEVEL];
if (state.showPing !== s[opts.SHOW_PING]) {
state.showPing = s[opts.SHOW_PING];
stats.toggle();
}
});
// initial app state
setState(app.state.eden);
keyboard.init();
joystick.init();
touch.init();
input.init()
stream.init();
screen.init();
@ -516,28 +545,72 @@ let [roomId, zone] = room.loadMaybe();
const wid = new URLSearchParams(document.location.search).get('wid');
// if from URL -> start game immediately!
socket.init(roomId, wid, zone);
api.transport = socket;
api.transport = {
send: socket.send,
keyboard: webrtc.keyboard,
mouse: webrtc.mouse,
}
// stats
let WEBRTC_STATS_RTT;
let VIDEO_BITRATE;
let GET_V_CODEC, SET_CODEC;
const bitrate = (() => {
let bytesPrev, timestampPrev
const w = [0, 0, 0, 0, 0, 0]
const n = w.length
let i = 0
return (now, bytes) => {
w[i++ % n] = timestampPrev ? Math.floor(8 * (bytes - bytesPrev) / (now - timestampPrev)) : 0
bytesPrev = bytes
timestampPrev = now
return Math.floor(w.reduce((a, b) => a + b) / n)
}
})()
stats.modules = [
{
mui: stats.mui(),
mui: stats.mui('', '<1'),
init() {
WEBRTC_STATS_RTT = (v) => (this.val = v)
},
},
{
mui: stats.mui('', '', false, () => ''),
init() {
GET_V_CODEC = (v) => (this.val = v + ' @ ')
}
},
{
mui: stats.mui('', '', false, () => ''),
init() {
sub(APP_VIDEO_CHANGED, (payload) => (this.val = `${payload.w}x${payload.h}`))
},
},
{
mui: stats.mui('', '', false, () => ' kb/s', 'stats-bitrate'),
init() {
VIDEO_BITRATE = (v) => (this.val = v)
}
},
{
async stats() {
const stats = await webrtc.stats();
if (!stats) return;
stats.forEach(report => {
const {nominated, currentRoundTripTime} = report;
if (!SET_CODEC && report.mimeType?.startsWith('video/')) {
GET_V_CODEC(report.mimeType.replace('video/', '').toLowerCase())
SET_CODEC = 1
}
const {nominated, currentRoundTripTime, type, kind} = report;
if (nominated && currentRoundTripTime !== undefined) {
WEBRTC_STATS_RTT(currentRoundTripTime * 1000);
}
if (type === 'inbound-rtp' && kind === 'video') {
VIDEO_BITRATE(bitrate(report.timestamp, report.bytesReceived))
}
});
},
enable() {
@ -548,5 +621,4 @@ stats.modules = [
},
}]
state.showPing = settings.loadOr(opts.SHOW_PING, true);
state.showPing && stats.toggle();
stats.toggle()

View file

@ -1,3 +1,8 @@
import {
pub,
TRANSFORM_CHANGE
} from 'event';
// UI
const page = document.getElementsByTagName('html')[0];
const gameBoy = document.getElementById('gamebody');
@ -47,6 +52,8 @@ const rescaleGameBoy = (targetWidth, targetHeight) => {
gameBoy.style['transform'] = transformations.join(' ');
}
new MutationObserver(() => pub(TRANSFORM_CHANGE)).observe(gameBoy, {attributeFilter: ['style']})
const os = () => {
const ua = window.navigator.userAgent;
// noinspection JSUnresolvedReference,JSDeprecatedSymbols

View file

@ -56,7 +56,6 @@ export const WORKER_LIST_FETCHED = 'workerListFetched';
export const GAME_ROOM_AVAILABLE = 'gameRoomAvailable';
export const GAME_SAVED = 'gameSaved';
export const GAME_LOADED = 'gameLoaded';
export const GAME_PLAYER_IDX = 'gamePlayerIndex';
export const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet'
export const GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots'
@ -83,9 +82,19 @@ export const KEY_PRESSED = 'keyPressed';
export const KEY_RELEASED = 'keyReleased';
export const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode';
export const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed';
export const KEYBOARD_KEY_DOWN = 'keyboardKeyDown';
export const KEYBOARD_KEY_UP = 'keyboardKeyUp';
export const AXIS_CHANGED = 'axisChanged';
export const CONTROLLER_UPDATED = 'controllerUpdated';
export const MOUSE_MOVED = 'mouseMoved'
export const MOUSE_PRESSED = 'mousePressed'
export const FULLSCREEN_CHANGE = 'fsc'
export const POINTER_LOCK_CHANGE = 'plc'
export const TRANSFORM_CHANGE = 'tc'
export const DPAD_TOGGLE = 'dpadToggle';
export const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled';
@ -95,3 +104,6 @@ export const RECORDING_TOGGLED = 'recordingToggle'
export const RECORDING_STATUS_CHANGED = 'recordingStatusChanged'
export const APP_VIDEO_CHANGED = 'appVideoChanged'
export const KB_MOUSE_FLAG = 'kbMouseFlag'
export const REFRESH_INPUT = 'refreshInput'

View file

@ -1,8 +1,4 @@
import {
sub,
MENU_PRESSED,
MENU_RELEASED
} from 'event';
import {MENU_PRESSED, MENU_RELEASED, sub} from 'event';
import {gui} from 'gui';
const TOP_POSITION = 102
@ -21,13 +17,6 @@ const games = (() => {
return list[index].title // selected by the game title, oof
},
set index(i) {
//-2 |
//-1 | |
// 0 < | <
// 1 | |
// 2 < < |
//+1 | |
//+2 |
index = i < -1 ? i = 0 :
i > list.length ? i = list.length - 1 :
(i % list.length + list.length) % list.length
@ -90,10 +79,41 @@ const ui = (() => {
let onTransitionEnd = () => ({})
//rootEl.addEventListener('transitionend', () => onTransitionEnd())
let items = []
const marque = (() => {
const speed = 1
const sep = ' '.repeat(10)
let el = null
let raf = 0
let txt = null
let w = 0
const move = () => {
const shift = parseFloat(getComputedStyle(el).left) - speed
el.style.left = w + shift < 1 ? `0px` : `${shift}px`
raf = requestAnimationFrame(move)
}
return {
reset() {
cancelAnimationFrame(raf)
el && (el.style.left = `0px`)
},
enable(cap) {
txt && (el.textContent = txt) // restore the text
el = cap
txt = el.textContent
el.textContent += sep
w = el.scrollWidth // keep the text width
el.textContent += txt
cancelAnimationFrame(raf)
raf = requestAnimationFrame(move)
}
}
})()
const item = (parent) => {
const title = parent.firstChild.firstChild
const desc = parent.children[1]
@ -106,16 +126,20 @@ const ui = (() => {
},
}
const isOverflown = () => title.scrollWidth > title.clientWidth
const _title = {
animate: () => title.classList.add('text-move'),
pick: () => title.classList.add('pick'),
reset: () => title.classList.remove('pick', 'text-move'),
pick: () => {
title.classList.add('pick')
isOverflown() && marque.enable(title)
},
reset: () => {
title.classList.remove('pick')
isOverflown() && marque.reset()
}
}
const clear = () => {
_title.reset()
// _desc.hide()
}
const clear = () => _title.reset()
return {
get description() {
@ -132,7 +156,7 @@ const ui = (() => {
rootEl.innerHTML = games.list.map(game =>
`<div class="menu-item">` +
`<div><span>${game.title}</span></div>` +
//`<div class="menu-item__info">${game.system}</div>` +
`<div class="menu-item__info">${game.system}</div>` +
`</div>`)
.join('')
items = [...rootEl.querySelectorAll('.menu-item')].map(x => item(x))
@ -191,21 +215,14 @@ const select = (index) => {
scroll.onShift = (delta) => select(games.index + delta)
let hasTransition = true // needed for cases when MENU_RELEASE called instead MENU_PRESSED
scroll.onStop = () => {
const item = ui.selected
if (item) {
item.title.pick()
item.title.animate()
// hasTransition ? (ui.onTransitionEnd = item.description.show) : item.description.show()
}
item && item.title.pick()
}
sub(MENU_PRESSED, (position) => {
if (games.empty()) return
ui.onTransitionEnd = ui.NO_TRANSITION
hasTransition = false
scroll.scroll(scroll.state.DRAG)
ui.selected && ui.selected.clear()
ui.drag.startPos(position)
@ -215,15 +232,14 @@ sub(MENU_RELEASED, (position) => {
if (games.empty()) return
ui.drag.stopPos(position)
select(ui.roundIndex)
hasTransition = !hasTransition
scroll.scroll(scroll.state.IDLE)
hasTransition = true
})
/**
* Game list module.
*/
export const gameList = {
disable: () => ui.selected?.clear(),
scroll: (x) => {
if (games.empty()) return
scroll.scroll(x)

View file

@ -175,8 +175,8 @@ const binding = (key = '', value = '', cb = () => ({})) => {
return el;
}
const show = (el) => {
el.classList.remove('hidden');
const show = (...els) => {
els.forEach(el => el.classList.remove('hidden'))
}
const inputN = (key = '', cb = () => ({}), current = 0) => {
@ -201,6 +201,23 @@ const toggle = (el, what) => {
what ? show(el) : hide(el)
}
const multiToggle = (elements = [], options = {list: []}) => {
if (!options.list.length || !elements.length) return
let i = 0
const setText = () => elements.forEach(el => el.innerText = options.list[i].caption)
const handleClick = () => {
options.list[i].cb()
i = (i + 1) % options.list.length
setText()
}
setText()
elements.forEach(el => el.addEventListener('click', handleClick))
}
const fadeIn = async (el, speed = .1) => {
el.style.opacity = '0';
el.style.display = 'block';
@ -252,6 +269,7 @@ export const gui = {
fragment,
hide,
inputN,
multiToggle,
panel,
select,
show,

View file

@ -1,5 +1,56 @@
export {joystick} from './joystick.js?v=3';
import {
REFRESH_INPUT,
KB_MOUSE_FLAG,
pub,
sub
} from 'event';
export {KEY} from './keys.js?v=3';
export {keyboard} from './keyboard.js?v=3'
export {retropad} from './retropad.js?v=3';
export {touch} from './touch.js?v=3';
import {joystick} from './joystick.js?v=3';
import {keyboard} from './keyboard.js?v=3'
import {pointer} from './pointer.js?v=3';
import {retropad} from './retropad.js?v=3';
import {touch} from './touch.js?v=3';
export {joystick, keyboard, pointer, retropad, touch};
const input_state = {
joystick: true,
keyboard: false,
pointer: true, // aka mouse
retropad: true,
touch: true,
kbm: false,
}
const init = () => {
keyboard.init()
joystick.init()
touch.init()
}
sub(KB_MOUSE_FLAG, () => {
input_state.kbm = true
pub(REFRESH_INPUT)
})
export const input = {
state: input_state,
init,
retropad: {
...retropad,
toggle(on = true) {
if (on === input_state.retropad) return
input_state.retropad = on
on ? retropad.enable() : retropad.disable()
}
},
set kbm(v) {
input_state.kbm = v
},
get kbm() {
return input_state.kbm
}
}

View file

@ -1,12 +1,14 @@
import {
pub,
sub,
KEYBOARD_TOGGLE_FILTER_MODE,
AXIS_CHANGED,
DPAD_TOGGLE,
KEY_PRESSED,
KEY_RELEASED,
KEYBOARD_KEY_PRESSED
KEYBOARD_KEY_PRESSED,
KEYBOARD_KEY_DOWN,
KEYBOARD_KEY_UP,
KEYBOARD_TOGGLE_FILTER_MODE,
} from 'event';
import {KEY} from 'input';
import {log} from 'log'
@ -47,11 +49,16 @@ const defaultMap = Object.freeze({
});
let keyMap = {};
// special mode for changing button bindings in the options
let isKeysFilteredMode = true;
// if the browser supports Keyboard Lock API (Firefox does not)
let hasKeyboardLock = ('keyboard' in navigator) && ('lock' in navigator.keyboard)
let locked = false
const remap = (map = {}) => {
settings.set(opts.INPUT_KEYBOARD_MAP, map);
log.info('Keyboard keys have been remapped')
log.debug('Keyboard keys have been remapped')
}
sub(KEYBOARD_TOGGLE_FILTER_MODE, data => {
@ -88,9 +95,16 @@ function onDpadToggle(checked) {
}
}
const lock = async (lock) => {
locked = lock
if (hasKeyboardLock) {
lock ? await navigator.keyboard.lock() : navigator.keyboard.unlock()
}
// if the browser doesn't support keyboard lock, it will be emulated
}
const onKey = (code, evt, state) => {
const key = keyMap[code]
if (key === undefined) return
if (dpadState[key] !== undefined) {
dpadState[key] = state
@ -103,7 +117,7 @@ const onKey = (code, evt, state) => {
return
}
}
pub(evt, {key: key})
pub(evt, {key: key, code: code})
}
sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
@ -115,28 +129,35 @@ export const keyboard = {
init: () => {
keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap);
const body = document.body;
// !to use prevent default as everyone
body.addEventListener('keyup', e => {
e.stopPropagation();
if (isKeysFilteredMode) {
onKey(e.code, KEY_RELEASED, false)
} else {
pub(KEYBOARD_KEY_PRESSED, {key: e.code});
e.stopPropagation()
!hasKeyboardLock && locked && e.preventDefault()
let lock = locked
// hack with Esc up when outside of lock
if (e.code === 'Escape') {
lock = true
}
}, false);
isKeysFilteredMode ?
(lock ? pub(KEYBOARD_KEY_UP, e) : onKey(e.code, KEY_RELEASED, false))
: pub(KEYBOARD_KEY_PRESSED, {key: e.code})
}, false)
body.addEventListener('keydown', e => {
e.stopPropagation();
if (isKeysFilteredMode) {
onKey(e.code, KEY_PRESSED, true)
} else {
pub(KEYBOARD_KEY_PRESSED, {key: e.code});
}
});
e.stopPropagation()
!hasKeyboardLock && locked && e.preventDefault()
log.info('[input] keyboard has been initialized');
isKeysFilteredMode ?
(locked ? pub(KEYBOARD_KEY_DOWN, e) : onKey(e.code, KEY_PRESSED, true)) :
pub(KEYBOARD_KEY_PRESSED, {key: e.code})
})
log.info('[input] keyboard has been initialized')
},
settings: {
remap
}
},
lock,
}

153
web/js/input/pointer.js Normal file
View file

@ -0,0 +1,153 @@
// Pointer (aka mouse) stuff
import {
MOUSE_PRESSED,
MOUSE_MOVED,
pub
} from 'event';
import {browser, env} from 'env';
const hasRawPointer = 'onpointerrawupdate' in window
const p = {dx: 0, dy: 0}
const move = (e, cb, single = false) => {
// !to fix ff https://github.com/w3c/pointerlock/issues/42
if (single) {
p.dx = e.movementX
p.dy = e.movementY
cb(p)
} else {
const _events = e.getCoalescedEvents?.()
if (_events && (hasRawPointer || _events.length > 1)) {
for (let i = 0; i < _events.length; i++) {
p.dx = _events[i].movementX
p.dy = _events[i].movementY
cb(p)
}
}
}
}
const _track = (el, cb, single) => {
const _move = (e) => {
move(e, cb, single)
}
el.addEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move)
return () => {
el.removeEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move)
}
}
const dpiScaler = () => {
let ex = 0
let ey = 0
let scaled = {dx: 0, dy: 0}
return {
scale(x, y, src_w, src_h, dst_w, dst_h) {
scaled.dx = x / (src_w / dst_w) + ex
scaled.dy = y / (src_h / dst_h) + ey
ex = scaled.dx % 1
ey = scaled.dy % 1
scaled.dx -= ex
scaled.dy -= ey
return scaled
}
}
}
const dpi = dpiScaler()
const handlePointerMove = (el, cb) => {
let w, h = 0
let s = false
const dw = 640, dh = 480
return (p) => {
({w, h, s} = cb())
pub(MOUSE_MOVED, s ? dpi.scale(p.dx, p.dy, w, h, dw, dh) : p)
}
}
const trackPointer = (el, cb) => {
let mpu, mpd
let noTrack
// disable coalesced mouse move events
const single = true
// coalesced event are broken since FF 120
const isFF = env.getBrowser === browser.firefox
const pm = handlePointerMove(el, cb)
return (enabled) => {
if (enabled) {
!noTrack && (noTrack = _track(el, pm, isFF || single))
mpu = pointer.handle.up(el)
mpd = pointer.handle.down(el)
return
}
mpu?.()
mpd?.()
noTrack?.()
noTrack = null
}
}
const handleDown = ((b = {b: null, p: true}) => (e) => {
b.b = e.button
pub(MOUSE_PRESSED, b)
})()
const handleUp = ((b = {b: null, p: false}) => (e) => {
b.b = e.button
pub(MOUSE_PRESSED, b)
})()
const autoHide = (el, time = 3000) => {
let tm
let move
const cl = el.classList
const hide = (force = false) => {
cl.add('no-pointer')
!force && el.addEventListener('pointermove', move)
}
move = () => {
cl.remove('no-pointer')
clearTimeout(tm)
tm = setTimeout(hide, time)
}
const show = () => {
clearTimeout(tm)
el.removeEventListener('pointermove', move)
cl.remove('no-pointer')
}
return {
autoHide: (on) => on ? show() : hide()
}
}
export const pointer = {
autoHide,
lock: async (el) => {
await el.requestPointerLock(/*{ unadjustedMovement: true}*/)
},
track: trackPointer,
handle: {
down: (el) => {
el.onpointerdown = handleDown
return () => (el.onpointerdown = null)
},
up: (el) => {
el.onpointerup = handleUp
return () => (el.onpointerup = null)
}
}
}

View file

@ -94,7 +94,8 @@ const _getState = () => {
const _poll = poll(pollingIntervalMs, sendControllerState)
export const retropad = {
poll: _poll,
enable: () => _poll.enable(),
disable: () => _poll.disable(),
setKeyState,
setAxisChanged,
}

View file

@ -39,6 +39,8 @@ const getKey = (el) => el.dataset.key
let dpadMode = true;
const deadZone = 0.1;
let enabled = false
function onDpadToggle(checked) {
if (dpadMode === checked) {
return //error?
@ -237,6 +239,8 @@ function handleMenuUp(evt) {
// Common events
function handleWindowMove(event) {
if (!enabled) return
event.preventDefault();
handleVpadJoystickMove(event);
handleMenuMove(event);
@ -303,6 +307,7 @@ playerSlider.onkeydown = (e) => {
*/
export const touch = {
init: () => {
enabled = true
// Bind events for menu
// TODO change this flow
pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown});
@ -316,10 +321,11 @@ export const touch = {
vpadState[getKey(el)] = false;
});
window.addEventListener('mousemove', handleWindowMove);
window.addEventListener('pointermove', handleWindowMove);
window.addEventListener('touchmove', handleWindowMove, {passive: false});
window.addEventListener('mouseup', handleWindowUp);
log.info('[input] touch input has been initialized');
}
},
toggle: (v) => v === undefined ? (enabled = !enabled) : (enabled = v)
}

View file

@ -9,7 +9,9 @@ import {
import {log} from 'log';
let connection;
let dataChannel;
let dataChannel
let keyboardChannel
let mouseChannel
let mediaStream;
let candidates = [];
let isAnswered = false;
@ -30,6 +32,16 @@ const start = (iceservers) => {
log.debug('[rtc] ondatachannel', e.channel.label)
e.channel.binaryType = "arraybuffer";
if (e.channel.label === 'keyboard') {
keyboardChannel = e.channel
return
}
if (e.channel.label === 'mouse') {
mouseChannel = e.channel
return
}
dataChannel = e.channel;
dataChannel.onopen = () => {
log.info('[rtc] the input channel has been opened');
@ -39,7 +51,10 @@ const start = (iceservers) => {
if (onData) {
dataChannel.onmessage = onData;
}
dataChannel.onclose = () => log.info('[rtc] the input channel has been closed');
dataChannel.onclose = () => {
inputReady = false
log.info('[rtc] the input channel has been closed')
}
}
connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
connection.onicegatheringstatechange = ice.onIceStateChange;
@ -62,8 +77,16 @@ const stop = () => {
connection = null;
}
if (dataChannel) {
dataChannel.close();
dataChannel = null;
dataChannel.close()
dataChannel = null
}
if (keyboardChannel) {
keyboardChannel?.close()
keyboardChannel = null
}
if (mouseChannel) {
mouseChannel?.close()
mouseChannel = null
}
candidates = [];
log.info('[rtc] WebRTC has been closed');
@ -162,7 +185,9 @@ export const webrtc = {
});
isFlushing = false;
},
input: (data) => dataChannel.send(data),
keyboard: (data) => keyboardChannel?.send(data),
mouse: (data) => mouseChannel?.send(data),
input: (data) => inputReady && dataChannel.send(data),
isConnected: () => connected,
isInputReady: () => inputReady,
stats: async () => {

View file

@ -30,7 +30,7 @@ const parseURLForRoom = () => {
};
sub(GAME_ROOM_AVAILABLE, data => {
room.setId(data.roomId);
room.id = data.roomId
room.save(data.roomId);
}, 1);
@ -38,8 +38,10 @@ sub(GAME_ROOM_AVAILABLE, data => {
* Game room module.
*/
export const room = {
getId: () => id,
setId: (id_) => {
get id() {
return id
},
set id(id_) {
id = id_;
roomLabel.value = id;
},
@ -51,7 +53,7 @@ export const room = {
localStorage.setItem('roomID', roomIndex);
},
load: () => localStorage.getItem('roomID'),
getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`,
getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.id)}`,
loadMaybe: () => {
// localStorage first
//roomID = loadRoomID();

View file

@ -1,8 +1,15 @@
import {
sub,
SETTINGS_CHANGED,
REFRESH_INPUT,
} from 'event';
import {env} from 'env';
import {input, pointer, keyboard} from 'input';
import {opts, settings} from 'settings';
import {SETTINGS_CHANGED, sub} from "event";
import {env} from "env";
import {gui} from 'gui';
const rootEl = document.getElementById('screen');
const rootEl = document.getElementById('screen')
const footerEl = document.getElementsByClassName('screen__footer')[0]
const state = {
components: [],
@ -10,30 +17,63 @@ const state = {
forceFullscreen: false,
}
const toggle = (component, force) => {
component && (state.current = component); // keep the last component
state.components.forEach(c => c.toggle(false));
state.current?.toggle(force);
component && !env.isMobileDevice && !state.current?.noFullscreen && state.forceFullscreen && fullscreen();
const toggle = async (component, force) => {
component && (state.current = component) // keep the last component
state.components.forEach(c => c.toggle(false))
state.current?.toggle(force)
state.forceFullscreen && fullscreen(true)
}
const init = () => {
state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false);
state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false)
sub(SETTINGS_CHANGED, () => {
state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN];
});
state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN]
})
}
const cursor = pointer.autoHide(rootEl, 2000)
const trackPointer = pointer.track(rootEl, () => {
const display = state.current;
return {...display.video.size, s: !!display?.hasDisplay}
})
const fullscreen = () => {
let h = parseFloat(getComputedStyle(rootEl, null)
.height
.replace('px', '')
)
env.display().toggleFullscreen(h !== window.innerHeight, rootEl);
if (state.current?.noFullscreen) return
let h = parseFloat(getComputedStyle(rootEl, null).height.replace('px', ''))
env.display().toggleFullscreen(h !== window.innerHeight, rootEl)
}
rootEl.addEventListener('fullscreenchange', () => {
state.current?.onFullscreen?.(document.fullscreenElement !== null)
const controls = async (locked = false) => {
if (!state.current?.hasDisplay) return
if (env.isMobileDevice) return
if (!input.kbm) return
if (locked) {
await pointer.lock(rootEl)
}
// oof, remove hover:hover when the pointer is forcibly locked,
// leaving the element in the hovered state
locked ? footerEl.classList.remove('hover') : footerEl.classList.add('hover')
trackPointer(locked)
await keyboard.lock(locked)
input.retropad.toggle(!locked)
}
rootEl.addEventListener('fullscreenchange', async () => {
const fs = document.fullscreenElement !== null
cursor.autoHide(!fs)
gui.toggle(footerEl, fs)
await controls(fs)
state.current?.onFullscreen?.(fs)
})
sub(REFRESH_INPUT, async () => {
await controls(document.fullscreenElement !== null)
})
export const screen = {

View file

@ -23,7 +23,6 @@ export const opts = {
MIRROR_SCREEN: 'mirror.screen',
VOLUME: 'volume',
FORCE_FULLSCREEN: 'force.fullscreen',
SHOW_PING: 'show.ping',
}
@ -229,6 +228,14 @@ const set = (key, value, updateProvider = true) => {
}
}
const changed = (key, obj, key2) => {
if (!store.settings.hasOwnProperty(key)) return
const newValue = store.settings[key]
const changed = newValue !== obj[key2]
changed && (obj[key2] = newValue)
return changed
}
const _reset = () => {
for (let _option of Object.keys(_defaults)) {
const value = _defaults[_option];
@ -340,6 +347,7 @@ export const settings = {
getStore,
get,
set,
changed,
remove,
import: _import,
export: _export,
@ -488,6 +496,8 @@ const render = function () {
case opts.INPUT_KEYBOARD_MAP:
_option(data).withName('Keyboard bindings')
.withClass('keyboard-bindings')
.withDescription(
'Bindings for RetroPad. There is an alternate ESC key [Shift+`] (tilde) for cores with keyboard+mouse controls (DosBox)')
.add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange)))
.build();
break;
@ -500,7 +510,6 @@ const render = function () {
case opts.VOLUME:
_option(data).withName('Volume (%)')
.add(gui.inputN(k, onChange, value))
.restartNeeded()
.build()
break;
case opts.FORCE_FULLSCREEN:
@ -511,12 +520,6 @@ const render = function () {
.add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox'))
.build()
break;
case opts.SHOW_PING:
_option(data).withName('Show ping')
.withDescription('Always display ping info on the screen')
.add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox'))
.build()
break;
default:
_option(data).withName(k).add(value).build();
}

View file

@ -110,19 +110,23 @@ const graph = (parent, opts = {
* Get cached module UI.
*
* HTML:
* <div><div>LABEL</div><span>VALUE</span>[<span><canvas/><span>]</div>
* `<div><div>LABEL</div><span>VALUE</span>[<span><canvas/><span>]</div>`
*
* @param label The name of the stat to show.
* @param nan A value to show when zero.
* @param withGraph True if to draw a graph.
* @param postfix Supposed to be the name of the stat passed as a function.
* @param cl Class of the UI div element.
* @returns {{el: HTMLDivElement, update: function}}
*/
const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => {
const moduleUi = (label = '', nan = '', withGraph = false, postfix = () => 'ms', cl = '') => {
const ui = document.createElement('div'),
_label = document.createElement('div'),
_value = document.createElement('span');
ui.append(_label, _value);
cl && ui.classList.add(cl)
let postfix_ = postfix;
let _graph;
@ -139,7 +143,7 @@ const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => {
const update = (value) => {
if (_graph) _graph.add(value);
// 203 (333) ms
_value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`;
_value.textContent = `${value < 1 ? nan : value}${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`;
}
const clear = () => {
@ -157,7 +161,7 @@ const module = (mod) => {
enable: () => ({}),
...mod,
_disable: function () {
mod.val = 0;
// mod.val = 0;
mod.disable && mod.disable();
mod.mui && mod.mui.clear();
},

View file

@ -1,13 +1,14 @@
import {
sub,
APP_VIDEO_CHANGED,
SETTINGS_CHANGED
} from 'event' ;
import {gui} from 'gui';
SETTINGS_CHANGED,
TRANSFORM_CHANGE
} from 'event';
import {log} from 'log';
import {opts, settings} from 'settings';
const videoEl = document.getElementById('stream');
const videoEl = document.getElementById('stream')
const mirrorEl = document.getElementById('mirror-stream')
const options = {
volume: 0.5,
@ -21,151 +22,151 @@ const state = {
timerId: null,
w: 0,
h: 0,
aspect: 4 / 3
aspect: 4 / 3,
ready: false
}
const mute = (mute) => (videoEl.muted = mute)
const _stream = () => {
const play = () => {
videoEl.play()
.then(() => log.info('Media can autoplay'))
.catch(error => {
log.error('Media failed to play', error);
});
.then(() => {
state.ready = true
videoEl.poster = ''
useCustomScreen(options.mirrorMode === 'mirror')
})
.catch(error => log.error('Can\'t autoplay', error))
}
const toggle = (show) => state.screen.toggleAttribute('hidden', show === undefined ? show : !show)
videoEl.onerror = (e) => {
// video playback failed - show a message saying why
switch (e.target.error.code) {
case e.target.error.MEDIA_ERR_ABORTED:
log.error('You aborted the video playback.');
break;
case e.target.error.MEDIA_ERR_NETWORK:
log.error('A network error caused the video download to fail part-way.');
break;
case e.target.error.MEDIA_ERR_DECODE:
log.error('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.');
break;
case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
log.error('The video could not be loaded, either because the server or network failed or because the format is not supported.');
break;
default:
log.error('An unknown video error occurred.');
break;
}
};
// Track resize even when the underlying media stream changes its video size
videoEl.addEventListener('resize', () => {
recalculateSize()
if (state.screen === videoEl) return
state.screen.setAttribute('width', videoEl.videoWidth)
state.screen.setAttribute('height', videoEl.videoHeight)
})
videoEl.addEventListener('loadedmetadata', () => {
if (state.screen !== videoEl) {
state.screen.setAttribute('width', videoEl.videoWidth);
state.screen.setAttribute('height', videoEl.videoHeight);
}
}, false);
videoEl.addEventListener('loadstart', () => {
videoEl.volume = options.volume;
videoEl.poster = options.poster;
}, false);
videoEl.addEventListener('play', () => {
videoEl.poster = '';
useCustomScreen(options.mirrorMode === 'mirror');
}, false);
videoEl.volume = options.volume / 100
videoEl.poster = options.poster
})
const screenToAspect = (el) => {
const w = window.screen.width ?? window.innerWidth;
const hh = el.innerHeight || el.clientHeight || 0;
const dw = (w - hh * state.aspect) / 2
videoEl.style.padding = `0 ${dw}px`
}
videoEl.onfocus = () => videoEl.blur()
videoEl.onerror = (e) => log.error('Playback error', e)
const onFullscreen = (y) => {
if (y) {
screenToAspect(document.fullscreenElement);
// chrome bug
const onFullscreen = (fullscreen) => {
const el = document.fullscreenElement
if (fullscreen) {
// timeout is due to a chrome bug
setTimeout(() => {
screenToAspect(document.fullscreenElement)
// aspect ratio calc
const w = window.screen.width ?? window.innerWidth
const hh = el.innerHeight || el.clientHeight || 0
const dw = (w - hh * state.aspect) / 2
state.screen.style.padding = `0 ${dw}px`
state.screen.classList.toggle('with-footer')
}, 1)
} else {
videoEl.style.padding = '0'
state.screen.style.padding = '0'
state.screen.classList.toggle('with-footer')
}
videoEl.classList.toggle('no-media-controls', !!y)
if (el === videoEl) {
videoEl.classList.toggle('no-media-controls', !fullscreen)
videoEl.blur()
}
}
const vs = {w: 1, h: 1}
const recalculateSize = () => {
const fullscreen = document.fullscreenElement !== null
const {aspect, screen} = state
let width, height
if (fullscreen) {
// we can't get the real <video> size on the screen without the black bars,
// so we're using one side (without the bar) for the length calc of another
const horizontal = videoEl.videoWidth > videoEl.videoHeight
width = horizontal ? aspect * screen.offsetHeight : screen.offsetWidth
height = horizontal ? screen.offsetHeight : aspect * screen.offsetWidth
} else {
({width, height} = screen.getBoundingClientRect())
}
vs.w = width
vs.h = height
}
const useCustomScreen = (use) => {
if (use) {
if (videoEl.paused || videoEl.ended) return;
if (videoEl.paused || videoEl.ended) return
let id = state.screen.getAttribute('id');
if (id === 'canvas-mirror') return;
if (state.screen === mirrorEl) return
const canvas = gui.create('canvas');
canvas.setAttribute('id', 'canvas-mirror');
canvas.setAttribute('hidden', '');
canvas.setAttribute('width', videoEl.videoWidth);
canvas.setAttribute('height', videoEl.videoHeight);
canvas.style['image-rendering'] = 'pixelated';
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.classList.add('game-screen');
toggle(false)
state.screen = mirrorEl
state.screen.setAttribute('width', videoEl.videoWidth)
state.screen.setAttribute('height', videoEl.videoHeight)
// stretch depending on the video orientation
const isPortrait = videoEl.videoWidth < videoEl.videoHeight;
canvas.style.width = isPortrait ? 'auto' : canvas.style.width;
// canvas.style.height = isPortrait ? canvas.style.height : 'auto';
const isPortrait = videoEl.videoWidth < videoEl.videoHeight
state.screen.style.width = isPortrait ? 'auto' : videoEl.videoWidth
let surface = canvas.getContext('2d');
videoEl.parentNode.insertBefore(canvas, videoEl.nextSibling);
toggle(false)
state.screen = canvas
toggle(true)
let surface = state.screen.getContext('2d')
state.ready && toggle(true)
state.timerId = setInterval(function () {
if (videoEl.paused || videoEl.ended || !surface) return;
surface.drawImage(videoEl, 0, 0);
}, options.mirrorUpdateRate);
if (videoEl.paused || videoEl.ended || !surface) return
surface.drawImage(videoEl, 0, 0)
}, options.mirrorUpdateRate)
} else {
clearInterval(state.timerId);
let mirror = state.screen;
state.screen = videoEl;
toggle(true);
if (mirror !== videoEl) {
mirror.parentNode.removeChild(mirror);
}
clearInterval(state.timerId)
toggle(false)
state.screen = videoEl
state.ready && toggle(true)
}
}
const init = () => {
options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none');
options.volume = settings.loadOr(opts.VOLUME, 50) / 100;
options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none')
options.volume = settings.loadOr(opts.VOLUME, 50)
sub(SETTINGS_CHANGED, () => {
const s = settings.get();
const newValue = s[opts.MIRROR_SCREEN];
if (newValue !== options.mirrorMode) {
useCustomScreen(newValue === 'mirror');
options.mirrorMode = newValue;
if (settings.changed(opts.MIRROR_SCREEN, options, 'mirrorMode')) {
useCustomScreen(options.mirrorMode === 'mirror')
}
});
if (settings.changed(opts.VOLUME, options, 'volume')) {
videoEl.volume = options.volume / 100
}
})
}
const fit = 'contain'
sub(APP_VIDEO_CHANGED, (payload) => {
const {w, h, a, s} = payload
const scale = !s ? 1 : s;
const ww = w * scale;
const hh = h * scale;
const scale = !s ? 1 : s
const ww = w * scale
const hh = h * scale
state.aspect = a
const a2 = (ww / hh).toFixed(6)
state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : fit
state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : 'contain'
state.h = hh
state.w = Math.floor(hh * a)
state.screen.setAttribute('width', '' + ww)
state.screen.setAttribute('height', '' + hh)
state.screen.style.aspectRatio = '' + state.aspect
recalculateSize()
})
sub(TRANSFORM_CHANGE, () => {
// cache stream element size when the interface is transformed
recalculateSize()
})
/**
@ -176,10 +177,15 @@ sub(APP_VIDEO_CHANGED, (payload) => {
*/
export const stream = {
audio: {mute},
video: {el: videoEl},
play: _stream,
video: {
el: videoEl,
get size() {
return vs
},
},
play,
toggle,
useCustomScreen,
hasDisplay: true,
init,
onFullscreen,
}