WASM Texture Caching
* Refactor texture caching in render.Engine: * New interface method: NewTexture(filename string, image.Image) * WASM immediately encodes the image to PNG and generates a JavaScript `Image()` object to load it with a data URI and keep it in memory. * SDL2 saves the bitmap to disk as it did before. * WASM: deprecate the sessionStorage for holding image data. Session storage methods panic if called. The image data is directly kept in Go memory as a js.Value holding an Image(). * Shared Memory workaround: the level.Chunk.ToBitmap() function is where chunk textures get cached, but it had no access to the render.Engine used in the game. The `pkg/shmem` package holds global pointers to common structures like the CurrentRenderEngine as a work-around. * Also shmem.Flash() so Doodle can make its d.Flash() function globally available, any sub-package can now flash text to the screen regardless of source code location. * JavaScript API for Doodads now has a global Flash() function available. * WASM: Handle window resize so Doodle can recompute its dimensions instead of scaling/shrinking the view.
This commit is contained in:
parent
48fc40ade4
commit
ba6892aa95
|
@ -1,5 +1,5 @@
|
||||||
function main() {
|
function main() {
|
||||||
log.Info("Azulian '%s' initialized!", Self.Doodad.Title);
|
Flash("Azulian '%s' initialized!", Self.Doodad.Title);
|
||||||
|
|
||||||
var playerSpeed = 12;
|
var playerSpeed = 12;
|
||||||
var gravity = 4;
|
var gravity = 4;
|
||||||
|
|
|
@ -28,10 +28,12 @@ func GetCanvas(id string) Canvas {
|
||||||
|
|
||||||
// ClientW returns the client width.
|
// ClientW returns the client width.
|
||||||
func (c Canvas) ClientW() int {
|
func (c Canvas) ClientW() int {
|
||||||
return c.Value.Get("clientWidth").Int()
|
return js.Global().Get("window").Get("innerWidth").Int()
|
||||||
|
// return c.Value.Get("clientWidth").Int()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientH returns the client height.
|
// ClientH returns the client height.
|
||||||
func (c Canvas) ClientH() int {
|
func (c Canvas) ClientH() int {
|
||||||
return c.Value.Get("clientHeight").Int()
|
return js.Global().Get("window").Get("innerHeight").Int()
|
||||||
|
// return c.Value.Get("clientHeight").Int()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
package canvas
|
package canvas
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kirsle.net/apps/doodle/lib/events"
|
"git.kirsle.net/apps/doodle/lib/events"
|
||||||
"git.kirsle.net/apps/doodle/lib/render"
|
"git.kirsle.net/apps/doodle/lib/render"
|
||||||
"git.kirsle.net/apps/doodle/pkg/wasm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine implements a rendering engine targeting an HTML canvas for
|
// Engine implements a rendering engine targeting an HTML canvas for
|
||||||
|
@ -20,8 +23,9 @@ type Engine struct {
|
||||||
ticks uint32
|
ticks uint32
|
||||||
|
|
||||||
// Private fields.
|
// Private fields.
|
||||||
events *events.State
|
events *events.State
|
||||||
running bool
|
running bool
|
||||||
|
textures map[string]*Texture // cached texture PNG images
|
||||||
|
|
||||||
// Event channel. WASM subscribes to events asynchronously using the
|
// Event channel. WASM subscribes to events asynchronously using the
|
||||||
// JavaScript APIs, whereas SDL2 polls the event queue which orders them
|
// JavaScript APIs, whereas SDL2 polls the event queue which orders them
|
||||||
|
@ -40,6 +44,7 @@ func New(canvasID string) (*Engine, error) {
|
||||||
width: canvas.ClientW(),
|
width: canvas.ClientW(),
|
||||||
height: canvas.ClientH(),
|
height: canvas.ClientH(),
|
||||||
queue: make(chan Event, 1024),
|
queue: make(chan Event, 1024),
|
||||||
|
textures: map[string]*Texture{},
|
||||||
}
|
}
|
||||||
|
|
||||||
return engine, nil
|
return engine, nil
|
||||||
|
@ -47,6 +52,14 @@ func New(canvasID string) (*Engine, error) {
|
||||||
|
|
||||||
// WindowSize returns the size of the canvas window.
|
// WindowSize returns the size of the canvas window.
|
||||||
func (e *Engine) WindowSize() (w, h int) {
|
func (e *Engine) WindowSize() (w, h int) {
|
||||||
|
// Good time to recompute it first?
|
||||||
|
var (
|
||||||
|
window = js.Global().Get("window")
|
||||||
|
width = window.Get("innerWidth").Int()
|
||||||
|
height = window.Get("innerHeight").Int()
|
||||||
|
)
|
||||||
|
e.canvas.Value.Set("width", width)
|
||||||
|
e.canvas.Value.Set("height", height)
|
||||||
return e.canvas.ClientW(), e.canvas.ClientH()
|
return e.canvas.ClientW(), e.canvas.ClientH()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,10 +83,58 @@ func (e *Engine) Present() error {
|
||||||
type Texture struct {
|
type Texture struct {
|
||||||
data string // data:image/png URI
|
data string // data:image/png URI
|
||||||
image js.Value // DOM image element
|
image js.Value // DOM image element
|
||||||
|
canvas js.Value // Warmed up canvas element
|
||||||
|
ctx2d js.Value // 2D drawing context for the canvas.
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTexture caches a texture from a bitmap.
|
||||||
|
func (e *Engine) NewTexture(filename string, img image.Image) (render.Texturer, error) {
|
||||||
|
var (
|
||||||
|
fh = bytes.NewBuffer([]byte{})
|
||||||
|
imageSize = img.Bounds().Size()
|
||||||
|
width = imageSize.X
|
||||||
|
height = imageSize.Y
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encode to PNG format.
|
||||||
|
if err := png.Encode(fh, img); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(fh.Bytes())
|
||||||
|
|
||||||
|
tex := &Texture{
|
||||||
|
data: dataURI,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preheat a cached Canvas object.
|
||||||
|
canvas := js.Global().Get("document").Call("createElement", "canvas")
|
||||||
|
canvas.Set("width", width)
|
||||||
|
canvas.Set("height", height)
|
||||||
|
tex.canvas = canvas
|
||||||
|
|
||||||
|
ctx2d := canvas.Call("getContext", "2d")
|
||||||
|
tex.ctx2d = ctx2d
|
||||||
|
|
||||||
|
// Load as a JS Image object.
|
||||||
|
image := js.Global().Call("eval", "new Image()")
|
||||||
|
image.Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
ctx2d.Call("drawImage", image, 0, 0)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
image.Set("src", tex.data)
|
||||||
|
tex.image = image
|
||||||
|
|
||||||
|
// Cache the texture in memory.
|
||||||
|
e.textures[filename] = tex
|
||||||
|
|
||||||
|
return tex, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Size returns the dimensions of the texture.
|
// Size returns the dimensions of the texture.
|
||||||
func (t *Texture) Size() render.Rect {
|
func (t *Texture) Size() render.Rect {
|
||||||
return render.NewRect(int32(t.width), int32(t.height))
|
return render.NewRect(int32(t.width), int32(t.height))
|
||||||
|
@ -82,37 +143,21 @@ func (t *Texture) Size() render.Rect {
|
||||||
// NewBitmap initializes a texture from a bitmap image. The image is stored
|
// NewBitmap initializes a texture from a bitmap image. The image is stored
|
||||||
// in HTML5 Session Storage.
|
// in HTML5 Session Storage.
|
||||||
func (e *Engine) NewBitmap(filename string) (render.Texturer, error) {
|
func (e *Engine) NewBitmap(filename string) (render.Texturer, error) {
|
||||||
if data, ok := wasm.GetSession(filename); ok {
|
if tex, ok := e.textures[filename]; ok {
|
||||||
img := js.Global().Get("document").Call("createElement", "img")
|
return tex, nil
|
||||||
img.Set("src", data)
|
|
||||||
return &Texture{
|
|
||||||
data: data,
|
|
||||||
image: img,
|
|
||||||
width: 60, // TODO
|
|
||||||
height: 60,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
panic("no bitmap for " + filename)
|
||||||
return nil, errors.New("no bitmap data stored for " + filename)
|
return nil, errors.New("no bitmap data stored for " + filename)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var TODO int
|
|
||||||
|
|
||||||
// Copy a texturer bitmap onto the canvas.
|
// Copy a texturer bitmap onto the canvas.
|
||||||
func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) {
|
func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) {
|
||||||
tex := t.(*Texture)
|
tex := t.(*Texture)
|
||||||
|
|
||||||
// image := js.Global().Get("document").Call("createElement", "img")
|
// e.canvas.ctx2d.Call("drawImage", tex.image, dist.X, dist.Y)
|
||||||
// image.Set("src", tex.data)
|
e.canvas.ctx2d.Call("drawImage", tex.canvas, dist.X, dist.Y)
|
||||||
|
|
||||||
// log.Info("drawing image just this once")
|
|
||||||
e.canvas.ctx2d.Call("drawImage", tex.image, dist.X, dist.Y)
|
|
||||||
// TODO++
|
|
||||||
// if TODO > 200 {
|
|
||||||
// log.Info("I exited at engine.Copy for canvas engine")
|
|
||||||
// os.Exit(0)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delay for a moment.
|
// Delay for a moment.
|
||||||
|
|
|
@ -15,6 +15,7 @@ const (
|
||||||
ClickEvent
|
ClickEvent
|
||||||
KeyEvent
|
KeyEvent
|
||||||
ResizeEvent
|
ResizeEvent
|
||||||
|
WindowEvent
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event object queues up asynchronous JavaScript events to be processed linearly.
|
// Event object queues up asynchronous JavaScript events to be processed linearly.
|
||||||
|
@ -36,6 +37,19 @@ type Event struct {
|
||||||
|
|
||||||
// AddEventListeners sets up bindings to collect events from the browser.
|
// AddEventListeners sets up bindings to collect events from the browser.
|
||||||
func (e *Engine) AddEventListeners() {
|
func (e *Engine) AddEventListeners() {
|
||||||
|
// Window resize.
|
||||||
|
js.Global().Get("window").Call(
|
||||||
|
"addEventListener",
|
||||||
|
"resize",
|
||||||
|
js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
e.queue <- Event{
|
||||||
|
Name: "resize",
|
||||||
|
Class: WindowEvent,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// Mouse movement.
|
// Mouse movement.
|
||||||
e.canvas.Value.Call(
|
e.canvas.Value.Call(
|
||||||
"addEventListener",
|
"addEventListener",
|
||||||
|
@ -149,6 +163,8 @@ func (e *Engine) Poll() (*events.State, error) {
|
||||||
|
|
||||||
for event := e.PollEvent(); event != nil; event = e.PollEvent() {
|
for event := e.PollEvent(); event != nil; event = e.PollEvent() {
|
||||||
switch event.Class {
|
switch event.Class {
|
||||||
|
case WindowEvent:
|
||||||
|
s.Resized.Push(true)
|
||||||
case MouseEvent:
|
case MouseEvent:
|
||||||
s.CursorX.Push(int32(event.X))
|
s.CursorX.Push(int32(event.X))
|
||||||
s.CursorY.Push(int32(event.Y))
|
s.CursorY.Push(int32(event.Y))
|
||||||
|
|
|
@ -2,6 +2,7 @@ package render
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"git.kirsle.net/apps/doodle/lib/events"
|
"git.kirsle.net/apps/doodle/lib/events"
|
||||||
|
@ -32,6 +33,7 @@ type Engine interface {
|
||||||
|
|
||||||
// Texture caching.
|
// Texture caching.
|
||||||
NewBitmap(filename string) (Texturer, error)
|
NewBitmap(filename string) (Texturer, error)
|
||||||
|
NewTexture(filename string, img image.Image) (Texturer, error)
|
||||||
Copy(t Texturer, src, dst Rect)
|
Copy(t Texturer, src, dst Rect)
|
||||||
|
|
||||||
// Delay for a moment using the render engine's delay method,
|
// Delay for a moment using the render engine's delay method,
|
||||||
|
|
|
@ -2,9 +2,12 @@ package sdl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"os"
|
||||||
|
|
||||||
"git.kirsle.net/apps/doodle/lib/render"
|
"git.kirsle.net/apps/doodle/lib/render"
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
|
"golang.org/x/image/bmp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Copy a texture into the renderer.
|
// Copy a texture into the renderer.
|
||||||
|
@ -25,6 +28,18 @@ type Texture struct {
|
||||||
height int32
|
height int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTexture caches an SDL texture from a bitmap.
|
||||||
|
func (r *Renderer) NewTexture(filename string, img image.Image) (render.Texturer, error) {
|
||||||
|
fh, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
err = bmp.Encode(fh, img)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Size returns the dimensions of the texture.
|
// Size returns the dimensions of the texture.
|
||||||
func (t *Texture) Size() render.Rect {
|
func (t *Texture) Size() render.Rect {
|
||||||
return render.NewRect(t.width, t.height)
|
return render.NewRect(t.width, t.height)
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"git.kirsle.net/apps/doodle/pkg/branding"
|
"git.kirsle.net/apps/doodle/pkg/branding"
|
||||||
"git.kirsle.net/apps/doodle/pkg/enum"
|
"git.kirsle.net/apps/doodle/pkg/enum"
|
||||||
"git.kirsle.net/apps/doodle/pkg/log"
|
"git.kirsle.net/apps/doodle/pkg/log"
|
||||||
|
"git.kirsle.net/apps/doodle/pkg/shmem"
|
||||||
"github.com/kirsle/golog"
|
"github.com/kirsle/golog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -56,6 +57,10 @@ func New(debug bool, engine render.Engine) *Doodle {
|
||||||
}
|
}
|
||||||
d.shell = NewShell(d)
|
d.shell = NewShell(d)
|
||||||
|
|
||||||
|
// Make the render engine globally available. TODO: for wasm/ToBitmap
|
||||||
|
shmem.CurrentRenderEngine = engine
|
||||||
|
shmem.Flash = d.Flash
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
log.Logger.Config.Level = golog.DebugLevel
|
log.Logger.Config.Level = golog.DebugLevel
|
||||||
// DebugOverlay = true // on by default in debug mode, F3 to disable
|
// DebugOverlay = true // on by default in debug mode, F3 to disable
|
||||||
|
|
|
@ -11,8 +11,8 @@ import (
|
||||||
"git.kirsle.net/apps/doodle/lib/render"
|
"git.kirsle.net/apps/doodle/lib/render"
|
||||||
"git.kirsle.net/apps/doodle/pkg/balance"
|
"git.kirsle.net/apps/doodle/pkg/balance"
|
||||||
"git.kirsle.net/apps/doodle/pkg/log"
|
"git.kirsle.net/apps/doodle/pkg/log"
|
||||||
|
"git.kirsle.net/apps/doodle/pkg/shmem"
|
||||||
"git.kirsle.net/apps/doodle/pkg/userdir"
|
"git.kirsle.net/apps/doodle/pkg/userdir"
|
||||||
"git.kirsle.net/apps/doodle/pkg/wasm"
|
|
||||||
"github.com/satori/go.uuid"
|
"github.com/satori/go.uuid"
|
||||||
"github.com/vmihailenco/msgpack"
|
"github.com/vmihailenco/msgpack"
|
||||||
)
|
)
|
||||||
|
@ -187,8 +187,9 @@ func (c *Chunk) ToBitmap(filename string, mask render.Color) error {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the image to bitmap file.
|
// Cache the texture data with the current renderer.
|
||||||
return wasm.StoreBitmap(filename, img)
|
_, err := shmem.CurrentRenderEngine.NewTexture(filename, img)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set proxies to the accessor and flags the texture as dirty.
|
// Set proxies to the accessor and flags the texture as dirty.
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"git.kirsle.net/apps/doodle/lib/render"
|
"git.kirsle.net/apps/doodle/lib/render"
|
||||||
"git.kirsle.net/apps/doodle/pkg/log"
|
"git.kirsle.net/apps/doodle/pkg/log"
|
||||||
|
"git.kirsle.net/apps/doodle/pkg/shmem"
|
||||||
"github.com/robertkrimen/otto"
|
"github.com/robertkrimen/otto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -67,6 +68,7 @@ func (vm *VM) Set(name string, v interface{}) error {
|
||||||
func (vm *VM) RegisterLevelHooks() error {
|
func (vm *VM) RegisterLevelHooks() error {
|
||||||
bindings := map[string]interface{}{
|
bindings := map[string]interface{}{
|
||||||
"log": log.Logger,
|
"log": log.Logger,
|
||||||
|
"Flash": shmem.Flash,
|
||||||
"RGBA": render.RGBA,
|
"RGBA": render.RGBA,
|
||||||
"Point": render.NewPoint,
|
"Point": render.NewPoint,
|
||||||
"Self": vm.Self, // i.e., the uix.Actor object
|
"Self": vm.Self, // i.e., the uix.Actor object
|
||||||
|
|
26
pkg/shmem/globals.go
Normal file
26
pkg/shmem/globals.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
package shmem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/doodle/lib/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shared globals for easy access throughout the app.
|
||||||
|
// Not an ideal place to keep things but *shrug*
|
||||||
|
var (
|
||||||
|
// Current render engine (i.e. SDL2 or HTML5 Canvas)
|
||||||
|
// The level.Chunk.ToBitmap() uses this to cache a texture image.
|
||||||
|
CurrentRenderEngine render.Engine
|
||||||
|
|
||||||
|
// Globally available Flash() function so we can emit text to the Doodle UI.
|
||||||
|
Flash func(string, ...interface{})
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Default Flash function in case the app misconfigures it. Output to the
|
||||||
|
// console in an obvious way.
|
||||||
|
Flash = func(tmpl string, v ...interface{}) {
|
||||||
|
fmt.Printf("[shmem.Flash] "+tmpl+"\n", v...)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
// +build !js
|
|
||||||
|
|
||||||
package wasm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.org/x/image/bmp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StoreBitmap stores a bitmap image to disk.
|
|
||||||
func StoreBitmap(filename string, img image.Image) error {
|
|
||||||
fh, err := os.Create(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
return bmp.Encode(fh, img)
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
// +build js,wasm
|
|
||||||
|
|
||||||
package wasm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"image"
|
|
||||||
"image/png"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StoreBitmap stores a bitmap image to sessionStorage as a data URL for PNG
|
|
||||||
// base64 encoded image.
|
|
||||||
func StoreBitmap(filename string, img image.Image) error {
|
|
||||||
var fh = bytes.NewBuffer([]byte{})
|
|
||||||
|
|
||||||
if err := png.Encode(fh, img); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var dataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(fh.Bytes())
|
|
||||||
|
|
||||||
SetSession(filename, dataURI)
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -9,11 +9,13 @@ import (
|
||||||
// SetSession sets a text value on sessionStorage.
|
// SetSession sets a text value on sessionStorage.
|
||||||
func SetSession(key string, value string) {
|
func SetSession(key string, value string) {
|
||||||
// b64 := base64.StdEncoding.EncodeToString(value)
|
// b64 := base64.StdEncoding.EncodeToString(value)
|
||||||
|
panic("SesSession: " + key)
|
||||||
js.Global().Get("sessionStorage").Call("setItem", key, value)
|
js.Global().Get("sessionStorage").Call("setItem", key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession retrieves a text value from sessionStorage.
|
// GetSession retrieves a text value from sessionStorage.
|
||||||
func GetSession(key string) (string, bool) {
|
func GetSession(key string) (string, bool) {
|
||||||
|
panic("GetSession: " + key)
|
||||||
var value js.Value
|
var value js.Value
|
||||||
value = js.Global().Get("sessionStorage").Call("getItem", key)
|
value = js.Global().Get("sessionStorage").Call("getItem", key)
|
||||||
return value.String(), value.Type() == js.TypeString
|
return value.String(), value.Type() == js.TypeString
|
||||||
|
|
Loading…
Reference in New Issue
Block a user