Prepare v0.8.0 for release
This commit is contained in:
parent
7866f618da
commit
f446ed9130
48
Changes.md
48
Changes.md
|
@ -1,15 +1,31 @@
|
||||||
# Changes
|
# Changes
|
||||||
|
|
||||||
## v0.8.0 (TBD)
|
## v0.8.0 (September 3, 2021)
|
||||||
|
|
||||||
To Do:
|
|
||||||
* Thief needs animations
|
|
||||||
* New levels
|
|
||||||
|
|
||||||
This release brings some new features, new doodads, and new levels.
|
This release brings some new features, new doodads, and new levels.
|
||||||
|
|
||||||
New features:
|
New features:
|
||||||
|
|
||||||
|
* **Checkpoints** for gameplay will ease the pain of dying to fire
|
||||||
|
pixels or Anvils by teleporting you back to the checkpoint instead
|
||||||
|
of resetting the whole level.
|
||||||
|
* The **Doodad Properties** window while editing a doodad grants access
|
||||||
|
to many features which were previously only available via the
|
||||||
|
`doodad` tool, such as:
|
||||||
|
* Edit metadata like the Title and Author of your doodad
|
||||||
|
* Set the default hitbox of your doodad.
|
||||||
|
* Attach, open, and delete the JavaScript for your doodad
|
||||||
|
* Manage tags (key/value store) on your doodads: how you can
|
||||||
|
communicate settings to the JavaScript which can receive the
|
||||||
|
tags via `Self.GetTag("name")`
|
||||||
|
* Some **Generic Doodad Scripts** are built in. Using only the in-game
|
||||||
|
tools, it is possible to create custom doodads which have some basic
|
||||||
|
in-game logic and you don't need to write any code. The generic
|
||||||
|
scripts include:
|
||||||
|
* Generic Solid: the hitbox is solid
|
||||||
|
* Generic Fire: its hitbox harms the player
|
||||||
|
* Generic Anvil: harmless, deadly when falling
|
||||||
|
* Generic Collectible Item: it goes in your inventory
|
||||||
* **All Characters are Playable!** Use the Link Tool to connect your
|
* **All Characters are Playable!** Use the Link Tool to connect your
|
||||||
Start Flag with another doodad on your level, and you will play
|
Start Flag with another doodad on your level, and you will play
|
||||||
**as** that doodad when the level starts. The Creature doodads are
|
**as** that doodad when the level starts. The Creature doodads are
|
||||||
|
@ -31,6 +47,18 @@ New doodads have been added:
|
||||||
* The **Blue Azulian** is now selectable from the Doodads menu. It
|
* The **Blue Azulian** is now selectable from the Doodads menu. It
|
||||||
behaves like the Red Azulian but moves at half the speed. The
|
behaves like the Red Azulian but moves at half the speed. The
|
||||||
Azulians can pick up items and open doors.
|
Azulians can pick up items and open doors.
|
||||||
|
* The **Checkpoint Flag** will remember the player's spot in the level.
|
||||||
|
Dying to fire pixels or Anvils no longer forces a restart of the
|
||||||
|
level - you can resume from your last checkpoint, or the Start Flag
|
||||||
|
by default.
|
||||||
|
|
||||||
|
New levels have been added:
|
||||||
|
|
||||||
|
* **Castle.level:** introduces the new Thief character. Castle-themed
|
||||||
|
level showing off various new doodads.
|
||||||
|
* **Thief 1.level:** a level where you play as the Thief! You need to
|
||||||
|
steal Small Keys from dozens of Azulians and even steal items back
|
||||||
|
from another Thief who has already stolen some of the keys.
|
||||||
|
|
||||||
Some doodads have changed behavior:
|
Some doodads have changed behavior:
|
||||||
|
|
||||||
|
@ -52,12 +80,11 @@ The user interface has been improved:
|
||||||
5. All: a classic view paging over all doodads (and doodads
|
5. All: a classic view paging over all doodads (and doodads
|
||||||
not fitting any of the above categories).
|
not fitting any of the above categories).
|
||||||
|
|
||||||
doodad edit-doodad --tag "categories=doors,gizmos" filename.doodad
|
|
||||||
|
|
||||||
New functions are available in the JavaScript API for custom doodads:
|
New functions are available in the JavaScript API for custom doodads:
|
||||||
|
|
||||||
* FailLevel(message string): global function that kills the player
|
* FailLevel(message string): global function that kills the player
|
||||||
with a custom death message.
|
with a custom death message.
|
||||||
|
* SetCheckpoint(Point): set the player respawn location
|
||||||
* Self.MoveTo(Point(x, y int))
|
* Self.MoveTo(Point(x, y int))
|
||||||
* Self.IsPlayer() bool
|
* Self.IsPlayer() bool
|
||||||
* Self.SetInventory(bool): turn on or off inventory. Keys and other
|
* Self.SetInventory(bool): turn on or off inventory. Keys and other
|
||||||
|
@ -69,6 +96,7 @@ New functions are available in the JavaScript API for custom doodads:
|
||||||
* Self.RemoveItem(filename string, quantity int)
|
* Self.RemoveItem(filename string, quantity int)
|
||||||
* Self.HasItem(filename string)
|
* Self.HasItem(filename string)
|
||||||
* Self.Inventory() map[string]int
|
* Self.Inventory() map[string]int
|
||||||
|
* Self.Hitbox() - also see Self.Hitbox.IsEmpty()
|
||||||
|
|
||||||
The Events.OnLeave() callback now receives a CollideEvent argument,
|
The Events.OnLeave() callback now receives a CollideEvent argument,
|
||||||
like OnCollide, instead of the useless actor ID string. Notable
|
like OnCollide, instead of the useless actor ID string. Notable
|
||||||
|
@ -87,6 +115,12 @@ Other miscellaneous changes:
|
||||||
* A **death barrier** will prevent Boy from falling forever on unbounded
|
* A **death barrier** will prevent Boy from falling forever on unbounded
|
||||||
maps should he somehow fall off the level. The death barrier is a
|
maps should he somehow fall off the level. The death barrier is a
|
||||||
Y value 1,000 pixels below the lowest pixel on your map.
|
Y value 1,000 pixels below the lowest pixel on your map.
|
||||||
|
* Mobile doodads no longer "moonwalk" when they change directions.
|
||||||
|
* A new color is added to all default palettes: "hint" (pink) for
|
||||||
|
writing hint notes.
|
||||||
|
* A maximum scroll speed on the "follow the player character" logic
|
||||||
|
makes for cooler animations when the character teleports around.
|
||||||
|
* Levels and Doodads are now sorted on the Open menu.
|
||||||
|
|
||||||
## v0.7.2 (July 19 2021)
|
## v0.7.2 (July 19 2021)
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ func init() {
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
if c.NArg() < 2 {
|
if c.NArg() < 2 {
|
||||||
return cli.NewExitError(
|
return cli.Exit(
|
||||||
"Usage: doodad convert <input.png...> <output.doodad>\n"+
|
"Usage: doodad convert <input.png...> <output.doodad>\n"+
|
||||||
" Image file types: png, bmp\n"+
|
" Image file types: png, bmp\n"+
|
||||||
" Drawing file types: level, doodad",
|
" Drawing file types: level, doodad",
|
||||||
|
@ -59,7 +59,7 @@ func init() {
|
||||||
// Parse the chroma key.
|
// Parse the chroma key.
|
||||||
chroma, err := render.HexColor(c.String("key"))
|
chroma, err := render.HexColor(c.String("key"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.NewExitError(
|
return cli.Exit(
|
||||||
"Chrome key not a valid color: "+err.Error(),
|
"Chrome key not a valid color: "+err.Error(),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
@ -76,22 +76,22 @@ func init() {
|
||||||
if inputType == extPNG || inputType == extBMP {
|
if inputType == extPNG || inputType == extBMP {
|
||||||
if outputType == extLevel || outputType == extDoodad {
|
if outputType == extLevel || outputType == extDoodad {
|
||||||
if err := imageToDrawing(c, chroma, inputFiles, outputFile); err != nil {
|
if err := imageToDrawing(c, chroma, inputFiles, outputFile); err != nil {
|
||||||
return cli.NewExitError(err.Error(), 1)
|
return cli.Exit(err.Error(), 1)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return cli.NewExitError("Image inputs can only output to Doodle drawings", 1)
|
return cli.Exit("Image inputs can only output to Doodle drawings", 1)
|
||||||
} else if inputType == extLevel || inputType == extDoodad {
|
} else if inputType == extLevel || inputType == extDoodad {
|
||||||
if outputType == extPNG || outputType == extBMP {
|
if outputType == extPNG || outputType == extBMP {
|
||||||
if err := drawingToImage(c, chroma, inputFiles, outputFile); err != nil {
|
if err := drawingToImage(c, chroma, inputFiles, outputFile); err != nil {
|
||||||
return cli.NewExitError(err.Error(), 1)
|
return cli.Exit(err.Error(), 1)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return cli.NewExitError("Doodle drawing inputs can only output to image files", 1)
|
return cli.Exit("Doodle drawing inputs can only output to image files", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.NewExitError("File types must be: png, bmp, level, doodad", 1)
|
return cli.Exit("File types must be: png, bmp, level, doodad", 1)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,13 +107,13 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
||||||
for i, filename := range inputFiles {
|
for i, filename := range inputFiles {
|
||||||
reader, err := os.Open(filename)
|
reader, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.NewExitError(err.Error(), 1)
|
return cli.Exit(err.Error(), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
img, format, err := image.Decode(reader)
|
img, format, err := image.Decode(reader)
|
||||||
log.Info("Parsed image %d of %d. Format: %s", i+1, len(inputFiles), format)
|
log.Info("Parsed image %d of %d. Format: %s", i+1, len(inputFiles), format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.NewExitError(err.Error(), 1)
|
return cli.Exit(err.Error(), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the bounding box information of the source image.
|
// Get the bounding box information of the source image.
|
||||||
|
@ -131,7 +131,7 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
||||||
chunkSize = imageSize.Y
|
chunkSize = imageSize.Y
|
||||||
}
|
}
|
||||||
} else if imageSize != imageBounds {
|
} else if imageSize != imageBounds {
|
||||||
return cli.NewExitError("your source images are not all the same dimensions", 1)
|
return cli.Exit("your source images are not all the same dimensions", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
images = append(images, img)
|
images = append(images, img)
|
||||||
|
@ -177,7 +177,7 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
||||||
|
|
||||||
err := doodad.WriteJSON(outputFile)
|
err := doodad.WriteJSON(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.NewExitError(err.Error(), 1)
|
return cli.Exit(err.Error(), 1)
|
||||||
}
|
}
|
||||||
case extLevel:
|
case extLevel:
|
||||||
log.Info("Output is a Level file: %s", outputFile)
|
log.Info("Output is a Level file: %s", outputFile)
|
||||||
|
@ -198,10 +198,10 @@ func imageToDrawing(c *cli.Context, chroma render.Color, inputFiles []string, ou
|
||||||
|
|
||||||
err := lvl.WriteJSON(outputFile)
|
err := lvl.WriteJSON(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.NewExitError(err.Error(), 1)
|
return cli.Exit(err.Error(), 1)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return cli.NewExitError("invalid output file: not a Doodle drawing", 1)
|
return cli.Exit("invalid output file: not a Doodle drawing", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -3,10 +3,12 @@ package commands
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.kirsle.net/apps/doodle/pkg/doodads"
|
"git.kirsle.net/apps/doodle/pkg/doodads"
|
||||||
"git.kirsle.net/apps/doodle/pkg/log"
|
"git.kirsle.net/apps/doodle/pkg/log"
|
||||||
|
"git.kirsle.net/go/render"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -32,6 +34,10 @@ func init() {
|
||||||
Name: "author",
|
Name: "author",
|
||||||
Usage: "set the doodad author",
|
Usage: "set the doodad author",
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "hitbox",
|
||||||
|
Usage: "set the doodad hitbox (X,Y,W,H or W,H format)",
|
||||||
|
},
|
||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
Name: "tag",
|
Name: "tag",
|
||||||
Aliases: []string{"t"},
|
Aliases: []string{"t"},
|
||||||
|
@ -56,7 +62,7 @@ func init() {
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
if c.NArg() < 1 {
|
if c.NArg() < 1 {
|
||||||
return cli.NewExitError(
|
return cli.Exit(
|
||||||
"Usage: doodad edit-doodad <filename.doodad>",
|
"Usage: doodad edit-doodad <filename.doodad>",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
@ -100,6 +106,34 @@ func editDoodad(c *cli.Context, filename string) error {
|
||||||
modified = true
|
modified = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.String("hitbox") != "" {
|
||||||
|
// Setting a hitbox, parse it out.
|
||||||
|
parts := strings.Split(c.String("hitbox"), ",")
|
||||||
|
var ints []int
|
||||||
|
for _, part := range parts {
|
||||||
|
a, err := strconv.Atoi(strings.TrimSpace(part))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ints = append(ints, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ints) == 2 {
|
||||||
|
dd.Hitbox = render.NewRect(ints[0], ints[1])
|
||||||
|
modified = true
|
||||||
|
} else if len(ints) == 4 {
|
||||||
|
dd.Hitbox = render.Rect{
|
||||||
|
X: ints[0],
|
||||||
|
Y: ints[1],
|
||||||
|
W: ints[2],
|
||||||
|
H: ints[3],
|
||||||
|
}
|
||||||
|
modified = true
|
||||||
|
} else {
|
||||||
|
return cli.Exit("Hitbox should be in X,Y,W,H or just W,H format, 2 or 4 numbers.", 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tags.
|
// Tags.
|
||||||
tags := c.StringSlice("tag")
|
tags := c.StringSlice("tag")
|
||||||
if len(tags) > 0 {
|
if len(tags) > 0 {
|
||||||
|
@ -152,7 +186,7 @@ func editDoodad(c *cli.Context, filename string) error {
|
||||||
|
|
||||||
if modified {
|
if modified {
|
||||||
if err := dd.WriteJSON(filename); err != nil {
|
if err := dd.WriteJSON(filename); err != nil {
|
||||||
return cli.NewExitError(fmt.Sprintf("Write error: %s", err), 1)
|
return cli.Exit(fmt.Sprintf("Write error: %s", err), 1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Warn("Note: No changes made to level")
|
log.Warn("Note: No changes made to level")
|
||||||
|
|
|
@ -58,7 +58,7 @@ func init() {
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
if c.NArg() < 1 {
|
if c.NArg() < 1 {
|
||||||
return cli.NewExitError(
|
return cli.Exit(
|
||||||
"Usage: doodad edit-level <filename.level>",
|
"Usage: doodad edit-level <filename.level>",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
@ -151,7 +151,7 @@ func editLevel(c *cli.Context, filename string) error {
|
||||||
|
|
||||||
if modified {
|
if modified {
|
||||||
if err := lvl.WriteFile(filename); err != nil {
|
if err := lvl.WriteFile(filename); err != nil {
|
||||||
return cli.NewExitError(fmt.Sprintf("Write error: %s", err), 1)
|
return cli.Exit(fmt.Sprintf("Write error: %s", err), 1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Warn("Note: No changes made to level")
|
log.Warn("Note: No changes made to level")
|
||||||
|
|
|
@ -26,7 +26,7 @@ func init() {
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
if c.NArg() != 2 {
|
if c.NArg() != 2 {
|
||||||
return cli.NewExitError(
|
return cli.Exit(
|
||||||
"Usage: doodad install-script <script.js> <filename.doodad>",
|
"Usage: doodad install-script <script.js> <filename.doodad>",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
@ -41,12 +41,12 @@ func init() {
|
||||||
// Read the JavaScript source.
|
// Read the JavaScript source.
|
||||||
javascript, err := ioutil.ReadFile(scriptFile)
|
javascript, err := ioutil.ReadFile(scriptFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.NewExitError(err.Error(), 1)
|
return cli.Exit(err.Error(), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
doodad, err := doodads.LoadJSON(doodadFile)
|
doodad, err := doodads.LoadJSON(doodadFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.NewExitError(
|
return cli.Exit(
|
||||||
fmt.Sprintf("Failed to read doodad file: %s", err),
|
fmt.Sprintf("Failed to read doodad file: %s", err),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
|
@ -34,9 +34,9 @@ func init() {
|
||||||
Usage: "print the script from a doodad file and exit",
|
Usage: "print the script from a doodad file and exit",
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "attachment",
|
Name: "attachment",
|
||||||
Aliases: []string{"a"},
|
Aliases: []string{"a"},
|
||||||
Usage: "print the contents of the attached filename to terminal",
|
Usage: "print the contents of the attached filename to terminal",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "verbose",
|
Name: "verbose",
|
||||||
|
@ -46,7 +46,7 @@ func init() {
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
if c.NArg() < 1 {
|
if c.NArg() < 1 {
|
||||||
return cli.NewExitError(
|
return cli.Exit(
|
||||||
"Usage: doodad show <.level .doodad ...>",
|
"Usage: doodad show <.level .doodad ...>",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
@ -58,12 +58,12 @@ func init() {
|
||||||
case enum.LevelExt:
|
case enum.LevelExt:
|
||||||
if err := showLevel(c, filename); err != nil {
|
if err := showLevel(c, filename); err != nil {
|
||||||
log.Error(err.Error())
|
log.Error(err.Error())
|
||||||
return cli.NewExitError("Error", 1)
|
return cli.Exit("Error", 1)
|
||||||
}
|
}
|
||||||
case enum.DoodadExt:
|
case enum.DoodadExt:
|
||||||
if err := showDoodad(c, filename); err != nil {
|
if err := showDoodad(c, filename); err != nil {
|
||||||
log.Error(err.Error())
|
log.Error(err.Error())
|
||||||
return cli.NewExitError("Error", 1)
|
return cli.Exit("Error", 1)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
log.Error("File %s: not a level or doodad", filename)
|
log.Error("File %s: not a level or doodad", filename)
|
||||||
|
@ -172,6 +172,7 @@ func showDoodad(c *cli.Context, filename string) error {
|
||||||
fmt.Printf(" Game version: %s\n", dd.GameVersion)
|
fmt.Printf(" Game version: %s\n", dd.GameVersion)
|
||||||
fmt.Printf(" Doodad title: %s\n", dd.Title)
|
fmt.Printf(" Doodad title: %s\n", dd.Title)
|
||||||
fmt.Printf(" Author: %s\n", dd.Author)
|
fmt.Printf(" Author: %s\n", dd.Author)
|
||||||
|
fmt.Printf(" Hitbox: %s\n", dd.Hitbox)
|
||||||
fmt.Printf(" Locked: %+v\n", dd.Locked)
|
fmt.Printf(" Locked: %+v\n", dd.Locked)
|
||||||
fmt.Printf(" Hidden: %+v\n", dd.Hidden)
|
fmt.Printf(" Hidden: %+v\n", dd.Hidden)
|
||||||
fmt.Printf(" Script size: %d bytes\n", len(dd.Script))
|
fmt.Printf(" Script size: %d bytes\n", len(dd.Script))
|
||||||
|
|
|
@ -4,7 +4,7 @@ package branding
|
||||||
const (
|
const (
|
||||||
AppName = "Sketchy Maze"
|
AppName = "Sketchy Maze"
|
||||||
Summary = "A drawing-based maze game"
|
Summary = "A drawing-based maze game"
|
||||||
Version = "0.7.2"
|
Version = "0.8.0"
|
||||||
Website = "https://www.sketchymaze.com"
|
Website = "https://www.sketchymaze.com"
|
||||||
Copyright = "2021 Noah Petherbridge"
|
Copyright = "2021 Noah Petherbridge"
|
||||||
Byline = "a game by Noah Petherbridge."
|
Byline = "a game by Noah Petherbridge."
|
||||||
|
|
|
@ -385,8 +385,8 @@ func (s *MainScene) Draw(d *Doodle) error {
|
||||||
|
|
||||||
// Version label
|
// Version label
|
||||||
s.labelVersion.MoveTo(render.Point{
|
s.labelVersion.MoveTo(render.Point{
|
||||||
X: (d.width / 2) - (s.labelVersion.Size().W / 2),
|
X: (d.width) - (s.labelVersion.Size().W) - 20,
|
||||||
Y: s.labelSubtitle.Point().Y + s.labelSubtitle.Size().H + 8,
|
Y: 20,
|
||||||
})
|
})
|
||||||
s.labelVersion.Present(d.Engine, s.labelVersion.Point())
|
s.labelVersion.Present(d.Engine, s.labelVersion.Point())
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user