diff --git a/CODEOWNERS b/CODEOWNERS index 7ae1cd56c..a01d757a4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,4 @@ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -* @openfrontio/review-approver +* @openfrontio/maintainer resources/lang @openfrontio/translation-approver -resources/lang/en.json @openfrontio/review-approver +resources/lang/en.json @openfrontio/maintainer diff --git a/index.html b/index.html index cad490b1c..50f2802c9 100644 --- a/index.html +++ b/index.html @@ -75,11 +75,11 @@ defer > - - + + diff --git a/map-generator/README.md b/map-generator/README.md index 3d9a83eb1..7fa443205 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -41,6 +41,27 @@ To process a subset of maps, pass a comma-separated list: - `../resources/maps//map16x.bin` - 1/16 scale (quarter dimensions) binary map data used for mini-maps. - `../resources/maps//thumbnail.webp` - WebP image thumbnail of the map. +## Command Line Flags + +- `--maps`: Optional comma-separated list of maps to process. + - ex: `go run . --maps=world,eastasia,big_plains` + +### Logging + +- `--log-level`: Explicitly sets the log level. + - ex: `go run . --log-level=debug` + - values: `ALL`, `DEBUG`, `INFO` (default), `WARN`, `ERROR`. +- `--verbose` or `-v`: Adds additional logging and prefixes logs with the `[mapname]`. Alias of `--log-level=DEBUG`. +- `--debug-performance`: Adds additional logging for performance-based recommendations, sets `--log-level=DEBUG`. +- `--debug-removal`: Adds additional logging of removed island and lake position/size, sets `--log-level=DEBUG`. + +The Generator outputs logs using `slog` with standard log-levels, and an additional ALL level. + +The `--verbose`, `-v`, `--debug-performance`, and `--debug-removal` flags all set the log level to `DEBUG`. +`debug-performance` and `debug-removal` are opt-in on top of the debug log level, as they can produce wordy output. You must pass the specific flag to see the corresponding logs if the `log-level` is set to `DEBUG`. + +Setting `--log-level=ALL` will output all possible logs, including all `DEBUG` tiers, regardless of whether the specific flags are passed. + ## Create image.png The map-generator will process your input file at `assets/maps//image.png` to generate the map diff --git a/map-generator/assets/maps/britannia/info.json b/map-generator/assets/maps/britannia/info.json index b72c54cbb..5d6ddd2b4 100644 --- a/map-generator/assets/maps/britannia/info.json +++ b/map-generator/assets/maps/britannia/info.json @@ -98,8 +98,7 @@ }, { "coordinates": [404, 1146], - "name": "Fermanagh", - "flag": "gb-nir" + "name": "Fermanagh" } ] } diff --git a/map-generator/assets/maps/italia/info.json b/map-generator/assets/maps/italia/info.json index 99465ddda..ea87c2146 100644 --- a/map-generator/assets/maps/italia/info.json +++ b/map-generator/assets/maps/italia/info.json @@ -3,8 +3,7 @@ "nations": [ { "coordinates": [1038, 993], - "name": "Kingdom of the Two Sicilies", - "flag": "custom:Kingdom of the Two Sicilies" + "name": "Kingdom of the Two Sicilies" }, { "coordinates": [370, 1137], @@ -18,8 +17,7 @@ }, { "coordinates": [625, 534], - "name": "Tuscany", - "flag": "custom:Tuscany" + "name": "Tuscany" }, { "coordinates": [595, 190], @@ -28,13 +26,11 @@ }, { "coordinates": [469, 386], - "name": "Modena", - "flag": "custom:Modena" + "name": "Modena" }, { "coordinates": [391, 254], - "name": "Parma", - "flag": "custom:Parma" + "name": "Parma" }, { "coordinates": [361, 68], @@ -43,8 +39,7 @@ }, { "coordinates": [278, 774], - "name": "Kingdom of Sardinia", - "flag": "custom:Kingdom of Sardinia" + "name": "Kingdom of Sardinia" }, { "coordinates": [29, 266], @@ -58,8 +53,7 @@ }, { "coordinates": [1238, 349], - "name": "Ottoman Empire", - "flag": "custom:Ottoman Empire2" + "name": "Ottoman Empire" } ] } diff --git a/map-generator/assets/maps/montreal/info.json b/map-generator/assets/maps/montreal/info.json index 0cb6c3e75..85d3756a6 100644 --- a/map-generator/assets/maps/montreal/info.json +++ b/map-generator/assets/maps/montreal/info.json @@ -3,17 +3,17 @@ "nations": [ { "coordinates": [800, 430], - "flag": "quebec", + "flag": "Quebec", "name": "Laval" }, { "coordinates": [1110, 930], - "flag": "quebec", + "flag": "Quebec", "name": "Royal Mount park" }, { "coordinates": [1220, 1360], - "flag": "quebec", + "flag": "Quebec", "name": "Hochelaga Archipelago" }, { @@ -23,42 +23,42 @@ }, { "coordinates": [1400, 1000], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Lambert" }, { "coordinates": [500, 130], - "flag": "quebec", + "flag": "Quebec", "name": "Blainville" }, { "coordinates": [350, 650], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Eustache" }, { "coordinates": [200, 1350], - "flag": "quebec", + "flag": "Quebec", "name": "Perrot Island" }, { "coordinates": [25, 950], - "flag": "quebec", + "flag": "Quebec", "name": "Kanesatake Lands" }, { "coordinates": [50, 450], - "flag": "quebec", + "flag": "Quebec", "name": "Mirabel" }, { "coordinates": [650, 1450], - "flag": "quebec", + "flag": "Quebec", "name": "Chateauguay" }, { "coordinates": [1330, 300], - "flag": "quebec", + "flag": "Quebec", "name": "Pointe-aux-Trembles" } ] diff --git a/map-generator/assets/maps/straitofgibraltar/info.json b/map-generator/assets/maps/straitofgibraltar/info.json index edc797671..1a826d7e7 100644 --- a/map-generator/assets/maps/straitofgibraltar/info.json +++ b/map-generator/assets/maps/straitofgibraltar/info.json @@ -3,8 +3,7 @@ "nations": [ { "coordinates": [1941, 1031], - "name": "Rif", - "flag": "Rif" + "name": "Rif" }, { "coordinates": [2733, 1190], @@ -28,13 +27,11 @@ }, { "coordinates": [1271, 1393], - "name": "Shilha", - "flag": "Shilha" + "name": "Shilha" }, { "coordinates": [1555, 258], - "name": "Andalusia", - "flag": "Andalusia" + "name": "Andalusia" } ] } diff --git a/map-generator/logger.go b/map-generator/logger.go new file mode 100644 index 000000000..204158b11 --- /dev/null +++ b/map-generator/logger.go @@ -0,0 +1,213 @@ +// This is the custom logger providing the multi-level and flag-based logging for +// the map-generator. It uses slog. +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "strings" + "sync" +) + +type LogFlags struct { + logLevel string // The log-level (most -> least wordy): ALL, DEBUG, INFO (default), WARN, ERROR + verbose bool // sets log-level=DEBUG + performance bool // opts-in to performance checks and sets log-level=DEBUG + removal bool // opts-in to island/lake removal logging and sets log-level=DEBUG +} + +// LevelAll is a custom log Level that outputs all messages, regardless of other passed flags +const LevelAll = slog.Level(-8) + +// PerformanceLogTag is a slog attribute used to tag performance-related log messages. +var PerformanceLogTag = slog.String("tag", "performance") + +// RemovalLogTag is a slog attribute used to tag land/water removal-related log messages. +var RemovalLogTag = slog.String("tag", "removal") + +// DetermineLogLevel determines the log level based on the LogFlags +// It prioritizes the log level flag over the default, and switches to debug if performance or removal flags are set. +func DetermineLogLevel( + logFlags LogFlags) slog.Level { + + var level = slog.LevelInfo + if logFlags.verbose { + level = slog.LevelDebug + } + + // switch to debug if any of the optional flags is enabled + if logFlags.performance || logFlags.removal { + level = slog.LevelDebug + } + + // parse the log-level input string to the slog.Level type + if logFlags.logLevel != "" { + switch strings.ToLower(logFlags.logLevel) { + case "all": + level = LevelAll + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + fmt.Printf("invalid log level: %s, defaulting to info\n", logFlags.logLevel) + level = slog.LevelInfo + } + } + return level +} + +// GeneratorLogger is a custom slog.Handler that outputs logs based on log level and additional LogFlags. +type GeneratorLogger struct { + opts slog.HandlerOptions + w io.Writer + mu *sync.Mutex + attrs []slog.Attr + prefix string + flags LogFlags +} + +// NewGeneratorLogger creates a new GeneratorLogger. +// It initializes a handler with specific output, options, and flags +func NewGeneratorLogger( + out io.Writer, + opts *slog.HandlerOptions, + flags LogFlags) *GeneratorLogger { + + h := &GeneratorLogger{ + w: out, + mu: &sync.Mutex{}, + flags: flags, + } + if opts != nil { + h.opts = *opts + } + if h.opts.Level == nil { + h.opts.Level = slog.LevelInfo + } + return h +} + +// Enabled checks if a given log level is enabled for this handler. +func (h *GeneratorLogger) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.opts.Level.Level() +} + +// Handle processes a log record. +// It decides whether to output each record based on log level, flags, and if the map is a test map +// On output, it formats the log message with any extra formatting +func (h *GeneratorLogger) Handle(_ context.Context, r slog.Record) error { + isPerformanceLog := false + isRemovalLog := false + isTestMap := false + + var mapName string + + findAttrs := func(a slog.Attr) { + if a.Equal(PerformanceLogTag) { + isPerformanceLog = true + } + if a.Equal(RemovalLogTag) { + isRemovalLog = true + } + if a.Key == "map" { + mapName = a.Value.String() + } + if a.Key == "isTest" { + isTestMap = a.Value.Bool() + } + } + + // Check record attributes for performance tag and map name + r.Attrs(func(a slog.Attr) bool { + findAttrs(a) + return true + }) + + // Check handler's own attributes for performance tag and map name + for _, a := range h.attrs { + findAttrs(a) + } + + // Don't log messages if the flags are not set + // If the log level is set to LevelAll, disregard + if h.opts.Level != LevelAll && isPerformanceLog && !h.flags.performance { + return nil + } + if h.opts.Level != LevelAll && (isRemovalLog && !h.flags.removal) { + return nil + } + + // dont log performance messages for test maps + if isPerformanceLog && isTestMap { + return nil + } + + buf := &bytes.Buffer{} + + // Add map name as a prefix in log Level DEBUG and ALL + if (h.opts.Level == slog.LevelDebug || h.opts.Level == LevelAll) && mapName != "" { + mapName = strings.Trim(mapName, `"`) + fmt.Fprintf(buf, "[%s] ", mapName) + } + + // Add prefix for performance messages + if isPerformanceLog { + fmt.Fprintf(buf, "[PERF] ") + } + + if h.prefix != "" { + fmt.Fprintf(buf, "%s ", h.prefix) + } + + fmt.Fprintln(buf, r.Message) + + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.w.Write(buf.Bytes()) + return err +} + +// WithAttrs returns a new handler with the given attributes added. +func (h *GeneratorLogger) WithAttrs(attrs []slog.Attr) slog.Handler { + newHandler := *h + newHandler.attrs = append(newHandler.attrs, attrs...) + return &newHandler +} + +// WithGroup returns a new handler with the given group name. +// The group name is added as a prefix to subsequent log messages. +func (h *GeneratorLogger) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + newHandler := *h + if newHandler.prefix != "" { + newHandler.prefix += "." + } + newHandler.prefix += name + return &newHandler +} + +type loggerKey struct{} + +// LoggerFromContext retrieves the logger from the context. +// If no logger is found, it returns the default logger. +func LoggerFromContext(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +// ContextWithLogger returns a new context with the provided logger. +func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey{}, logger) +} diff --git a/map-generator/main.go b/map-generator/main.go index b05070bfd..b6da02593 100644 --- a/map-generator/main.go +++ b/map-generator/main.go @@ -1,19 +1,18 @@ package main import ( + "context" "encoding/json" "flag" "fmt" "log" + "log/slog" "os" "path/filepath" "strings" "sync" ) -// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument. -var mapsFlag string - // maps defines the registry of available maps to be processed. // Each entry contains the folder name and a flag indicating if it's a test map. // @@ -75,6 +74,12 @@ var maps = []struct { {Name: "world", IsTest: true}, } +// mapsFlag holds the comma-separated list of map names passed via the --maps command-line argument. +var mapsFlag string + +// logFlags holds all the flags related to configuring the map-generator logging +var logFlags LogFlags + // outputMapDir returns the absolute path to the directory where generated map files should be written. // It distinguishes between test and production output locations. func outputMapDir(isTest bool) (string, error) { @@ -104,7 +109,7 @@ func inputMapDir(isTest bool) (string, error) { // processMap handles the end-to-end generation for a single map. // It reads the source image and JSON, generates the terrain data, and writes the binary outputs and updated manifest. -func processMap(name string, isTest bool) error { +func processMap(ctx context.Context, name string, isTest bool) error { outputMapBaseDir, err := outputMapDir(isTest) if err != nil { return fmt.Errorf("failed to get map directory: %w", err) @@ -135,7 +140,7 @@ func processMap(name string, isTest bool) error { } // Generate maps - result, err := GenerateMap(GeneratorArgs{ + result, err := GenerateMap(ctx, GeneratorArgs{ ImageBuffer: imageBuffer, RemoveSmall: !isTest, // Don't remove small islands for test maps Name: name, @@ -230,7 +235,11 @@ func loadTerrainMaps() error { mapItem := mapItem go func() { defer wg.Done() - if err := processMap(mapItem.Name, mapItem.IsTest); err != nil { + mapLogTag := slog.String("map", mapItem.Name) + testLogTag := slog.Bool("isTest", mapItem.IsTest) + logger := slog.Default().With(mapLogTag).With(testLogTag) + ctx := ContextWithLogger(context.Background(), logger) + if err := processMap(ctx, mapItem.Name, mapItem.IsTest); err != nil { errChan <- err } }() @@ -254,8 +263,23 @@ func loadTerrainMaps() error { // It parses flags and triggers the map generation process. func main() { flag.StringVar(&mapsFlag, "maps", "", "optional comma-separated list of maps to process. ex: --maps=world,eastasia,big_plains") + flag.StringVar(&logFlags.logLevel, "log-level", "", "Explicitly sets the log level to one of: ALL, DEBUG, INFO (default), WARN, ERROR.") + flag.BoolVar(&logFlags.verbose, "verbose", false, "Adds additional logging and prefixes logs with the [mapname]. Alias of log-level=DEBUG.") + flag.BoolVar(&logFlags.verbose, "v", false, "-verbose shorthand") + flag.BoolVar(&logFlags.performance, "log-performance", false, "Adds additional logging for performance-based recommendations, sets log-level=DEBUG") + flag.BoolVar(&logFlags.removal, "log-removal", false, "Adds additional logging of removed island and lake position/size, sets log-level=DEBUG") flag.Parse() + logger := slog.New(NewGeneratorLogger( + os.Stdout, + &slog.HandlerOptions{ + Level: DetermineLogLevel(logFlags), + }, + logFlags, + )) + + slog.SetDefault(logger) + if err := loadTerrainMaps(); err != nil { log.Fatalf("Error generating terrain maps: %v", err) } diff --git a/map-generator/map_generator.go b/map-generator/map_generator.go index f27ea0fda..1c0dffdd3 100644 --- a/map-generator/map_generator.go +++ b/map-generator/map_generator.go @@ -2,20 +2,25 @@ package main import ( "bytes" + "context" "fmt" "image" "image/color" "image/png" - "log" "math" "github.com/chai2010/webp" ) -// The smallest a body of land or lake can be, all smaller are removed const ( + // The smallest a body of land or lake can be, all smaller are removed minIslandSize = 30 minLakeSize = 200 + // the recommended max area pixel size for input images + minRecommendedPixelSize = 2000000 + maxRecommendedPixelSize = 3000000 + // the recommended max number of land tiles in the output bin at full size + maxRecommendedLandTileCount = 3000000 ) // Holds raw RGBA image data for the thumbnail @@ -94,7 +99,8 @@ type GeneratorArgs struct { // // Misc Notes // - It normalizes map width/height to multiples of 4 for the mini map downscaling. -func GenerateMap(args GeneratorArgs) (MapResult, error) { +func GenerateMap(ctx context.Context, args GeneratorArgs) (MapResult, error) { + logger := LoggerFromContext(ctx) img, err := png.Decode(bytes.NewReader(args.ImageBuffer)) if err != nil { return MapResult{}, fmt.Errorf("failed to decode PNG: %w", err) @@ -107,7 +113,12 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { width = width - (width % 4) height = height - (height % 4) - log.Printf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height) + logger.Info(fmt.Sprintf("Processing Map: %s, dimensions: %dx%d", args.Name, width, height)) + + area := width * height + if area < minRecommendedPixelSize || area > maxRecommendedPixelSize { + logger.Debug(fmt.Sprintf("Map area %d pixels is outside recommended range (%d - %d)", area, minRecommendedPixelSize, maxRecommendedPixelSize), PerformanceLogTag) + } // Initialize terrain grid terrain := make([][]Terrain, width) @@ -137,16 +148,16 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { } } - removeSmallIslands(terrain, args.RemoveSmall) - processWater(terrain, args.RemoveSmall) + removeSmallIslands(ctx, terrain, args.RemoveSmall) + processWater(ctx, terrain, args.RemoveSmall) terrain4x := createMiniMap(terrain) - processWater(terrain4x, false) + processWater(ctx, terrain4x, false) terrain16x := createMiniMap(terrain4x) - processWater(terrain16x, false) + processWater(ctx, terrain16x, false) - thumb := createMapThumbnail(terrain4x, 0.5) + thumb := createMapThumbnail(ctx, terrain4x, 0.5) webp, err := convertToWebP(ThumbData{ Data: thumb.Pix, Width: thumb.Bounds().Dx(), @@ -156,9 +167,20 @@ func GenerateMap(args GeneratorArgs) (MapResult, error) { return MapResult{}, fmt.Errorf("failed to save thumbnail: %w", err) } - mapData, mapNumLandTiles := packTerrain(terrain) - mapData4x, numLandTiles4x := packTerrain(terrain4x) - mapData16x, numLandTiles16x := packTerrain(terrain16x) + mapData, mapNumLandTiles := packTerrain(ctx, terrain) + mapData4x, numLandTiles4x := packTerrain(ctx, terrain4x) + mapData16x, numLandTiles16x := packTerrain(ctx, terrain16x) + + logger.Debug(fmt.Sprintf("Land Tile Count (1x): %d", mapNumLandTiles)) + logger.Debug(fmt.Sprintf("Land Tile Count (4x): %d", numLandTiles4x)) + logger.Debug(fmt.Sprintf("Land Tile Count (16x): %d", numLandTiles16x)) + + if mapNumLandTiles == 0 { + return MapResult{}, fmt.Errorf("Map has 0 land tiles") + } + if mapNumLandTiles > maxRecommendedLandTileCount { + logger.Debug(fmt.Sprintf("Map has more land tiles (%d) than recommended maximum (%d)", mapNumLandTiles, maxRecommendedLandTileCount), PerformanceLogTag) + } return MapResult{ Map: MapInfo{ @@ -242,8 +264,9 @@ func createMiniMap(tm [][]Terrain) [][]Terrain { // It marks Land tiles as shoreline if they neighbor Water, and Water tiles as // shoreline if they neighbor Land. // Returns a list of coordinates for all shoreline Water tiles found. -func processShore(terrain [][]Terrain) []Coord { - log.Println("Identifying shorelines") +func processShore(ctx context.Context, terrain [][]Terrain) []Coord { + logger := LoggerFromContext(ctx) + logger.Info("Identifying shorelines") var shorelineWaters []Coord width := len(terrain) height := len(terrain[0]) @@ -280,8 +303,9 @@ func processShore(terrain [][]Terrain) []Coord { // processDistToLand calculates the distance of water tiles from the nearest land. // It uses a Breadth-First Search (BFS) starting from the shoreline water tiles. // The distance is stored in the Magnitude field of the Water tiles. -func processDistToLand(shorelineWaters []Coord, terrain [][]Terrain) { - log.Println("Setting Water tiles magnitude = Manhattan distance from nearest land") +func processDistToLand(ctx context.Context, shorelineWaters []Coord, terrain [][]Terrain) { + logger := LoggerFromContext(ctx) + logger.Info("Setting Water tiles magnitude = Manhattan distance from nearest land") width := len(terrain) height := len(terrain[0]) @@ -362,8 +386,9 @@ func getNeighborCoords(x, y int, terrain [][]Terrain) []Coord { // It finds all connected water bodies and marks the largest one as Ocean. // If removeSmall is true, lakes smaller than minLakeSize are converted to Land. // Finally, it triggers shoreline identification and distance-to-land calculations. -func processWater(terrain [][]Terrain, removeSmall bool) { - log.Println("Processing water bodies") +func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) { + logger := LoggerFromContext(ctx) + logger.Info("Processing water bodies") visited := make(map[string]bool) type waterBody struct { @@ -408,13 +433,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) { for _, coord := range largestWaterBody.coords { terrain[coord.X][coord.Y].Ocean = true } - log.Printf("Identified ocean with %d water tiles", largestWaterBody.size) + logger.Info(fmt.Sprintf("Identified ocean with %d water tiles", largestWaterBody.size)) if removeSmall { // Remove small water bodies - log.Println("Searching for small water bodies for removal") + logger.Info("Searching for small water bodies for removal") for w := 1; w < len(waterBodies); w++ { if waterBodies[w].size < minLakeSize { + logger.Debug(fmt.Sprintf("Removing small lake at %d,%d (size %d)", waterBodies[w].coords[0].X, waterBodies[w].coords[0].Y, waterBodies[w].size), RemovalLogTag) smallLakes++ for _, coord := range waterBodies[w].coords { terrain[coord.X][coord.Y].Type = Land @@ -422,15 +448,14 @@ func processWater(terrain [][]Terrain, removeSmall bool) { } } } - log.Printf("Identified and removed %d bodies of water smaller than %d tiles", - smallLakes, minLakeSize) + logger.Info(fmt.Sprintf("Identified and removed %d bodies of water smaller than %d tiles", smallLakes, minLakeSize)) } // Process shorelines and distances - shorelineWaters := processShore(terrain) - processDistToLand(shorelineWaters, terrain) + shorelineWaters := processShore(ctx, terrain) + processDistToLand(ctx, shorelineWaters, terrain) } else { - log.Println("No water bodies found in the map") + logger.Info("No water bodies found in the map") } } @@ -465,7 +490,8 @@ func getArea(x, y int, terrain [][]Terrain, visited map[string]bool) []Coord { // removeSmallIslands identifies and removes small land masses from the terrain. // If removeSmall is true, any removed bodies are converted to Water. -func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { +func removeSmallIslands(ctx context.Context, terrain [][]Terrain, removeSmall bool) { + logger := LoggerFromContext(ctx) if !removeSmall { return } @@ -501,6 +527,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { for _, body := range landBodies { if body.size < minIslandSize { + logger.Debug(fmt.Sprintf("Removing small island at %d,%d (size %d)", body.coords[0].X, body.coords[0].Y, body.size), RemovalLogTag) smallIslands++ for _, coord := range body.coords { terrain[coord.X][coord.Y].Type = Water @@ -509,8 +536,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { } } - log.Printf("Identified and removed %d islands smaller than %d tiles", - smallIslands, minIslandSize) + logger.Info(fmt.Sprintf("Identified and removed %d islands smaller than %d tiles", smallIslands, minIslandSize)) } // packTerrain serializes the terrain grid into a byte slice. @@ -521,7 +547,7 @@ func removeSmallIslands(terrain [][]Terrain, removeSmall bool) { // - Bits 0-4: Magnitude (0-31). For Water, this is (Distance / 2). // // Returns the packed data and the count of land tiles. -func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { +func packTerrain(ctx context.Context, terrain [][]Terrain) (data []byte, numLandTiles int) { width := len(terrain) height := len(terrain[0]) packedData := make([]byte, width*height) @@ -553,15 +579,16 @@ func packTerrain(terrain [][]Terrain) (data []byte, numLandTiles int) { } } - logBinaryAsBits(packedData, 8) + logBinaryAsBits(ctx, packedData, 8) return packedData, numLandTiles } // createMapThumbnail generates an RGBA image representation of the terrain. // It scales the map dimensions based on the provided quality factor. // Each pixel's color is determined by the terrain type and magnitude via getThumbnailColor. -func createMapThumbnail(terrain [][]Terrain, quality float64) *image.RGBA { - log.Println("Creating thumbnail") +func createMapThumbnail(ctx context.Context, terrain [][]Terrain, quality float64) *image.RGBA { + logger := LoggerFromContext(ctx) + logger.Info("Creating thumbnail") srcWidth := len(terrain) srcHeight := len(terrain[0]) @@ -664,7 +691,8 @@ func getThumbnailColor(t Terrain) RGBA { // logBinaryAsBits logs the binary representation of the first 'length' bytes of data. // It is a helper function for debugging packed terrain data. -func logBinaryAsBits(data []byte, length int) { +func logBinaryAsBits(ctx context.Context, data []byte, length int) { + logger := LoggerFromContext(ctx) if length > len(data) { length = len(data) } @@ -673,7 +701,7 @@ func logBinaryAsBits(data []byte, length int) { for i := 0; i < length; i++ { bits += fmt.Sprintf("%08b ", data[i]) } - log.Printf("Binary data (bits): %s", bits) + logger.Info(fmt.Sprintf("Binary data (bits): %s", bits)) } // createCombinedBinary combines the info JSON, map data, and mini-map data into a single binary buffer. diff --git a/package-lock.json b/package-lock.json index 446dd7af2..a5a6901b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,7 +89,7 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-sh": "^0.17.4", "protobufjs": "^7.5.3", - "sinon": "^21.0.0", + "sinon": "^21.0.1", "sinon-chai": "^4.0.0", "tailwindcss": "^4.1.18", "tsconfig-paths": "^4.2.0", @@ -2957,9 +2957,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.0.tgz", + "integrity": "sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2967,14 +2967,13 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -3951,6 +3950,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -6847,9 +6906,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -9142,17 +9201,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -11081,16 +11132,16 @@ "license": "MIT" }, "node_modules/sinon": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", - "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", + "@sinonjs/fake-timers": "^15.1.0", + "@sinonjs/samsam": "^8.0.3", + "diff": "^8.0.2", "supports-color": "^7.2.0" }, "funding": { @@ -11684,9 +11735,9 @@ "license": "MIT" }, "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" diff --git a/package.json b/package.json index 5db065892..705132ef6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"", "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", - "test": "vitest run", + "test": "vitest run && vitest run tests/server", "perf": "npx tsx tests/perf/*.ts", "test:coverage": "vitest run --coverage", "format": "prettier --ignore-unknown --write .", @@ -75,7 +75,7 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-sh": "^0.17.4", "protobufjs": "^7.5.3", - "sinon": "^21.0.0", + "sinon": "^21.0.1", "sinon-chai": "^4.0.0", "tailwindcss": "^4.1.18", "tsconfig-paths": "^4.2.0", diff --git a/resources/images/CheckmarkIconWhite.svg b/resources/images/CheckmarkIconWhite.svg new file mode 100644 index 000000000..ef1abfe12 --- /dev/null +++ b/resources/images/CheckmarkIconWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/images/helpModal/controlPanel.webp b/resources/images/helpModal/controlPanel.webp index c4bdd0faf..f0dd9168e 100644 Binary files a/resources/images/helpModal/controlPanel.webp and b/resources/images/helpModal/controlPanel.webp differ diff --git a/resources/images/helpModal/eventsPanel.webp b/resources/images/helpModal/eventsPanel.webp index d7fb116da..6479df76c 100644 Binary files a/resources/images/helpModal/eventsPanel.webp and b/resources/images/helpModal/eventsPanel.webp differ diff --git a/resources/images/helpModal/eventsPanelAttack.webp b/resources/images/helpModal/eventsPanelAttack.webp index 71ec2b647..d183d57be 100644 Binary files a/resources/images/helpModal/eventsPanelAttack.webp and b/resources/images/helpModal/eventsPanelAttack.webp differ diff --git a/resources/images/helpModal/infoMenu2.webp b/resources/images/helpModal/infoMenu2.webp index 2eab61991..c448c8923 100644 Binary files a/resources/images/helpModal/infoMenu2.webp and b/resources/images/helpModal/infoMenu2.webp differ diff --git a/resources/images/helpModal/infoMenu2Ally.webp b/resources/images/helpModal/infoMenu2Ally.webp index 360f6314c..5f892cf26 100644 Binary files a/resources/images/helpModal/infoMenu2Ally.webp and b/resources/images/helpModal/infoMenu2Ally.webp differ diff --git a/resources/images/helpModal/leaderboard2.webp b/resources/images/helpModal/leaderboard2.webp index 2afde3369..64599293b 100644 Binary files a/resources/images/helpModal/leaderboard2.webp and b/resources/images/helpModal/leaderboard2.webp differ diff --git a/resources/images/helpModal/playerInfoOverlay.webp b/resources/images/helpModal/playerInfoOverlay.webp index 0500feb9c..51a548f03 100644 Binary files a/resources/images/helpModal/playerInfoOverlay.webp and b/resources/images/helpModal/playerInfoOverlay.webp differ diff --git a/resources/lang/bg.json b/resources/lang/bg.json index 2fef4934e..562270927 100644 --- a/resources/lang/bg.json +++ b/resources/lang/bg.json @@ -7,6 +7,7 @@ }, "common": { "close": "Затвори", + "back": "Назад", "available": "Наличен", "preset_max": "Макс", "summary_send": "Изпрати", @@ -17,26 +18,42 @@ "cap_tooltip": "Оставащ капацитет на получателя", "target_dead": "Целта бе елиминирана", "target_dead_note": "Не можеш да изпращаш ресурси на елиминиран играч.", - "none": "Няма" + "none": "Няма", + "copied": "Копирано!", + "click_to_copy": "Кликни, за да копираш" }, "main": { "title": "OpenFront (АЛФА)", "join_discord": "Discord", "login_discord": "Влез с Discord", + "sign_in": "Вход", + "discord_avatar_alt": "Discord профилна снимка", + "user_avatar_alt": "Профилната снимка на {username}", "checking_login": "Проверяване на входа...", "logged_in": "Влезли сте!", "log_out": "Излез от профила си", - "create_lobby": "Създай частна игра", - "join_lobby": "Присъедини се към частна игра", - "single_player": "Самостоятелна игра", + "create": "Създай частна игра", + "join": "Присъедини се към частна игра", + "solo": "Самостоятелно", "instructions": "Инструкции", + "game_info": "Информация за играта", "wiki": "Wiki", "privacy_policy": "Поверителност", "terms_of_service": "Условия за ползване", - "reddit": "Reddit" + "copyright": "© OpenFront™ and Contributors", + "reddit": "Reddit", + "play": "Играй", + "news": "Новини", + "store": "Магазин", + "settings": "Настройки", + "keys": "Клавиши", + "stats": "Статистики", + "account": "Акаунт", + "help": "Помощ", + "menu": "Меню", + "pick_pattern": "Избери шаблон!" }, "news": { - "see_all_releases": "Виж всички издания", "github_link": "в GitHub", "title": "Бележки по изданието" }, @@ -64,7 +81,7 @@ "ui_gold": "Злато - Количеството злато, което притежаваш и скоростта, с която го получаваш.", "ui_attack_ratio": "Съотношение на атака - Количеството войници, които ще се използват при атака. Можеш да коригираш съотношението на атака, използвайки плъзгача. Притежаването на повече атакуващи войници от тези в защита ще доведе до по-малка загуба на войници при атака, докато разполагането с по-малко ще увеличи щетите, нанесени на атакуващите ти войници. Ефектът не надхвърля съотношения 2:1.", "ui_events": "Панел за събития", - "ui_events_desc": "Панелът за събития показва най-новите събития, заявки и съобщения от бърз чат. Някои примери са:", + "ui_events_desc": "Панелът за събития показва най-актуалните събития, заявки и съобщения от бърз чат. Някои примери са:", "ui_events_alliance": "Съюз - Заявките за съюз могат да бъдат приети или отхвърлени. Съюзниците могат да споделят ресурси и войници, но не могат да се атакуват взаимно. Кликането върху \"Фокусиране\" премества изгледа върху играча, изпратил заявката.", "ui_events_attack": "Атаки - Показани са атаките срещу теб, както и твоите собствени атаки. Кликни върху съобщението, за да центрираш изгледа върху атаката, ракетата или лодката (транспортен кораб). Можеш да оттеглиш войниците си, като кликнеш върху червения бутон X. Това ще струва живота на 25% от атакуващите ти войници. Ако оттеглиш атака с лодка, лодката се връща в началната си точка и ще атакува там, ако тази земя е била превзета от друг. Ракетите не могат да бъдат оттеглени, след като бъдат изстреляни.", "ui_events_quickchat": "Бърз чат - Тук можеш да видиш изпратените и получените съобщения в чата. Изпрати съобщение до играч, като кликнеш върху иконката за бърз чат в менюто му с информация.", @@ -83,6 +100,8 @@ "radial_attack": "Отвориш менюто за атака.", "radial_info": "Отвориш информационното меню.", "radial_boat": "Изпратиш лодка (транспортен кораб) за атака на избраното място. Възможно е само, ако имаш достъп до вода.", + "radial_donate_troops": "Дариш войници на съюзника, на когото си отворил радиалното меню, еквивалентни на процента на плъзгача за съотношение на атака.", + "radial_donate_gold": "Отваря плъзгащото меню за даряване на злато, за да можеш бързо да изпратиш злато на съюзниците.", "radial_close": "Затвориш менюто.", "info_title": "Информационно меню", "info_enemy_desc": "Съдържа информация като име на избрания играч, злато, войници, дали е спряна търговията с теб, изпратени ракети към теб и дали играчът е предател. Спряната търговия означава, че няма да получаваш злато от него и той няма да ти изпраща злато чрез търговски кораби. Ръчно (ако играчът кликне върху „Прекратяване на търговия“, което продължава, докато и двамата не кликнат върху „Започване на търговия“) или автоматично (ако си предал съюзника си, което продължава, докато не станете съюзници отново или след 5 минути). Показва се \"Да\" на \"Предател\" за 30 секунди, когато играчът е предал и нападнал играч, който е бил в съюз с него. Иконките по-долу представляват следните взаимодействия:", @@ -114,7 +133,7 @@ "build_silo": "Ракетен силоз", "build_silo_desc": "Позволява изстрелване на ракети.", "build_sam": "Противоракетна установка земя-въздух SAM", - "build_sam_desc": "Може да прихваща вражески ракети в своя обхват от 100 пиксела. Със 100% шанс да свали атомна бомба, 80% за водородна бомба и 50% за отделни бойни ракети на МИРВ. Противоракетната установка земя-въздух SAM има 7,5 секунди охлаждане.", + "build_sam_desc": "Може да прехваща вражески ракети в обхват от 100 пиксела. Противоракетната установка земя-въздух SAM има време за охлаждане от 7,5 секунди.", "build_atom": "Атомна бомба", "build_atom_desc": "Малка експлозивна бомба, която унищожава територия, сгради, кораби и лодки. Поражда се от най-близкия ракетен силоз и се приземява в областта, в която първо си щракнал, за да я построиш.", "build_hydrogen": "Водородна бомба", @@ -129,12 +148,15 @@ "icon_embargo": "Стоп знак за долар - Ембарго. Този играч е спрял да търгува с теб автоматично или ръчно.", "icon_request": "Пликче за писмо - молба за съюз. Този играч ти е изпратил молба за съюз.", "info_enemy_panel": "Информационно меню за врагове", - "exit_confirmation": "Сигурен ли си, че искаш да излезеш от играта?" + "exit_confirmation": "Сигурен ли си, че искаш да излезеш от играта?", + "bomb_direction": "Посока на дъгата на атомна/водородна бомба" }, "single_modal": { - "title": "Самостоятелна Игра", + "title": "Самостоятелно", "random_spawn": "Случайно появяване", "allow_alliances": "Позволяване на съюзничества", + "toggle_achievements": "Превключване на постижения", + "sign_in_for_achievements": "Впиши се, за да получаваш постижения", "options_title": "Опции", "bots": "Ботове: ", "bots_disabled": "Изключени", @@ -145,6 +167,8 @@ "infinite_troops": "Безкрайна популация", "compact_map": "Компактна карта", "max_timer": "Продължителност на играта (в минути)", + "max_timer_placeholder": "Минути", + "max_timer_invalid": "Моля, въведи валидна максимална стойност на таймера (1-120 минути)", "disable_nukes": "Изключване на ядрени оръжия", "enables_title": "Активиране на настройки", "start": "Започване на игра" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Акаунт", - "logged_in_as": "Вписан като {email}", + "connected_as": "Вписан като", + "stats_overview": "Преглед на статистики", + "link_discord": "Свържи Discord акаунт", + "log_out": "Изход от профила", + "sign_in_desc": "Впиши се, за да запазиш статистиките и напредъка си", + "or": "ИЛИ", + "email_placeholder": "Въведи имейл адреса си", + "get_magic_link": "Използвай магически линк", + "linked_account": "Вписан като {account_name}", "fetching_account": "Взима се информацията за профила...", - "logged_in_with_discord": "Вписал си се с Discord", - "recovery_email_sent": "Имейл за възстановяване бе изпратен на {email}" + "recovery_email_sent": "Имейл за възстановяване бе изпратен на {email}", + "not_found": "Не е намерен", + "clear_session": "Изчисти сесията", + "failed_to_send_recovery_email": "Грешка при изпращането на имейл за възстановяване", + "enter_email_address": "Моля, въведи имейл адрес" }, "stats_modal": { "title": "Статистики", @@ -167,11 +202,40 @@ "loading": "Зареждане...", "error": "Грешка при зареждането на клановите статистики", "no_stats": "Няма налични кланови статистики", + "no_data_yet": "Все още няма данни", "clan": "Клан", "games": "Игри", "win_score": "Резултат на победи", + "win_score_tooltip": "Претеглени победи въз основа на участието на клана и трудността на мача", "loss_score": "Резултат на загуби", - "win_loss_ratio": "П/З" + "loss_score_tooltip": "Претеглени загуби въз основа на участието на клана и трудността на мача", + "win_loss_ratio": "П/З", + "ratio": "Съотношение", + "rank": "Ранк", + "try_again": "Опитай отново" + }, + "game_info_modal": { + "title": "Информация за играта", + "players": "Играчи", + "atoms": "Атомни бомби", + "hydros": "Водородни бомби", + "mirv": "МИРВ", + "bombs": "Бомби", + "total_gold": "Общо", + "all_gold": "Всичко злато", + "trade": "Търговия", + "conquest_gold": "Завладяно злато от играч", + "stolen_gold": "Откраднато с военни кораби", + "num_of_conquests": "Брой завладяни играчи", + "duration": "Продължителност", + "survival_time": "Време на оцеляване", + "war": "Война", + "economy": "Икономика", + "conquests": "Завоевания", + "pirate": "Пират", + "conquered": "Завладяно", + "loading_game_info": "Зареждат се статистиките на играта", + "no_winner": "Играта завърши без победител (или победа на нация)" }, "map": { "map": "Карта", @@ -186,6 +250,7 @@ "asia": "Азия", "mars": "Марс", "southamerica": "Южна Америка", + "britanniaclassic": "Британия (Класическа)", "britannia": "Британия", "gatewaytotheatlantic": "Порта към Атлантика", "australia": "Австралия", @@ -196,7 +261,7 @@ "betweentwoseas": "Между Две Морета", "faroeislands": "Фарьорски острови", "deglaciatedantarctica": "Обезледена Антарктида", - "europeclassic": "Европа (класическа)", + "europeclassic": "Европа (Класическа)", "falklandislands": "Фолкландски острови", "baikal": "Байкал", "halkidiki": "Халкидики", @@ -206,19 +271,33 @@ "yenisei": "Енисей", "pluto": "Плутон", "montreal": "Монтреал", + "newyorkcity": "Ню Йорк", "achiran": "Ахиран", "baikalnukewars": "Байкал (Ядрени войни)", "fourislands": "Четири острова", "gulfofstlawrence": "Залив Сейнт Лорънс", - "lisbon": "Лисабон" + "lisbon": "Лисабон", + "svalmel": "Свалмел", + "manicouagan": "Маникуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпински", + "twolakes": "Две езера", + "straitofhormuz": "Ормузки проток", + "surrounded": "Обкражен", + "didier": "Дидиер", + "didierfrance": "Дидиер (Франция)", + "amazonriver": "Река Амазонка" }, "map_categories": { "continental": "Континентално", "regional": "Регионално", - "fantasy": "Друго" + "fantasy": "Друго", + "special": "Специално", + "arcade": "Аркада" }, "map_component": { - "loading": "Зареждане..." + "loading": "Зареждане...", + "error": "Грешка" }, "private_lobby": { "title": "Присъединяване към частна игра", @@ -229,42 +308,55 @@ "checking": "Проверяване на частна игра...", "not_found": "Не е намерена частната игра. Моля, провери ID-то и опитай отново.", "error": "Възникна грешка. Моля, опитай отново или се свържи с екипа за поддръжка.", - "joined_waiting": "Присъединяването е успешно! Чакане за започване на играта...", - "version_mismatch": "Тази игра е създадена на различна версия. Не можеш да бъдеш присъединен." + "joined_waiting": "Присъедини се към лобито! Чакаме хостът да започне...", + "version_mismatch": "Тази игра е създадена на различна версия. Не можеш да бъдеш присъединен.", + "disabled_units": "Изключване на военни единици" }, "public_lobby": { "join": "Присъединяване към следващата игра", "waiting": "чакащи играчи", - "teams_Duos": "по 2-ма (Дуос)", - "teams_Trios": "по 3-ма (Триос)", - "teams_Quads": "по 4-ма (Куадс)", + "teams_Duos": "{team_count} отбора по 2-ма (Дуос)", + "teams_Trios": "{team_count} отбора по 3-ма (Триос)", + "teams_Quads": "{team_count} отбора по 4-ма (Куадс)", + "waiting_for_players": "Изчакване на играчи", + "starting_game": "Играта се стартира…", "teams_hvn": "Хора срещу Нации", + "teams_hvn_detailed": "{num} Души vs {num} Нации", "teams": "{num} отбора", - "players_per_team": "по {num}" + "players_per_team": "по {num}", + "started": "Стартирана" }, "matchmaking_modal": { - "title": "Мачмейкинг", + "title": "1v1 Ранков мачмейкинг (АЛФА)", "connecting": "Свързване със сървъра за мачмейкинг...", "searching": "Търси се игра...", - "waiting_for_game": "Изчаква се да започне играта..." + "waiting_for_game": "Изчаква се да започне играта...", + "elo": "Твоето ЕЛО: {elo}" }, "username": { "enter_username": "Въведи потребителско име", "not_string": "Потребителското име трябва да е символен низ.", "too_short": "Потребителското име трябва да е дълго поне {min} символа.", "too_long": "Потребителското име не трябва да надвишава {max} символа.", - "invalid_chars": "Потребителското име може да съдържа само букви, цифри, интервали, долни черти и [квадратни скоби]." + "invalid_chars": "Потребителското име може да съдържа само букви, цифри, интервали и долни черти.", + "tag": "ТАГ", + "tag_too_short": "Клановият таг трябва да съдържа от 2 до 5 буквено-цифрови знака.", + "tag_invalid_chars": "Клановият таг може да съдържа само букви и цифри." }, "host_modal": { - "title": "Частна игра", + "title": "Създай частна игра", + "label": "Частна", "mode": "Начин на игра", "team_count": "Брой отбори", + "team_type": "Вид на отбора", "options_title": "Опции", "bots": "Ботове: ", "bots_disabled": "Изключени", + "player_immunity_duration": "Продължителност на военния имунитет (минути)", "nations": "Нации: ", "disable_nations": "Изключване на нации", "max_timer": "Продължителност на играта (в минути)", + "mins_placeholder": "Минути", "instant_build": "Незабавно построяване", "infinite_gold": "Безкрайно злато", "donate_gold": "Даряване на злато", @@ -283,7 +375,11 @@ "assigned_teams": "Назначени отбори", "empty_teams": "Празни отбори", "empty_team": "Празен", - "remove_player": "Премахване на {username}" + "remove_player": "Премахване на {username}", + "teams_Duos": "Дуос (отбори по 2-ма)", + "teams_Trios": "Триос (отбори по 3-ма)", + "teams_Quads": "Куадс (отбори по 4-ма)", + "teams_Humans Vs Nations": "Хора срещу Нации" }, "team_colors": { "red": "Червен", @@ -301,16 +397,20 @@ "code_license": "Кодът е лицензиран съгласно AGPL-3.0 (без гаранция)" }, "difficulty": { - "difficulty": "Трудност", - "Easy": "Релаксирано", - "Medium": "Балансирано", - "Hard": "Интензивно", - "Impossible": "Невъзможно" + "difficulty": "Трудност на нациите", + "easy": "Лесно", + "medium": "Средно", + "hard": "Трудно", + "impossible": "Невъзможно" }, "game_mode": { "ffa": "Всеки срещу всеки (FFA)", "teams": "Отбори" }, + "public_game_modifier": { + "random_spawn": "Случайно появяване", + "compact_map": "Компактна карта" + }, "select_lang": { "title": "Изберете език" }, @@ -327,7 +427,7 @@ "factory": "Фабрика" }, "user_setting": { - "title": "Потребителски настройки", + "title": "Настройки", "tab_basic": "Базови настройки", "tab_keybinds": "Бързи клавиши", "dark_mode_label": "Тъмен режим", @@ -340,16 +440,18 @@ "special_effects_desc": "Превключване на специалните ефекти. Деактивиране, за да се увеличи производителността", "structure_sprites_label": "Структурни спрайтове", "structure_sprites_desc": "Превключване на структурните спрайтове", + "cursor_cost_label_label": "Цена на изграждане под курсора", + "cursor_cost_label_desc": "Показване на цената за изграждане под иконката на курсора", "anonymous_names_label": "Скрити имена", "anonymous_names_desc": "Скриване на истинските имена на играчите с произволни такива на екрана ти.", "lobby_id_visibility_label": "Скрити ID-та на частните игри", "lobby_id_visibility_desc": "Скриване на ID-то на частната игра при нейното създаване", + "toggle_visibility": "Превключване на видимостта", "left_click_label": "Щтракване на ляв бутон, за да се отвори менюто", "left_click_desc": "Когато е ВКЛЮЧЕНО, щракването с ляв бутон отваря менюто и атаките се извършват чрез бутона на меч. Когато е ИЗКЛЮЧЕНО, щракването с ляв бутон атакува директно.", "left_click_menu": "Меню ляв клик", "attack_ratio_label": "⚔️ Съотношение на атака", "attack_ratio_desc": "Какъв процент от Вашите войници да се изпратят в атака (1–100%)", - "troop_ratio_desc": "Коригиране на баланса между войници (за битка) и работници (за производство на злато) (1–100%)", "territory_patterns_label": "🏳️ Териториални шаблони", "territory_patterns_desc": "Избиране дали да се показват дизайни на шаблони на територия в играта", "performance_overlay_label": "Горен слой за производителност", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "Регулиране на това колко бързо се преструвате, че кодирате(x1–x100)", "easter_bug_count_label": "Брой грешки", "easter_bug_count_desc": "С колко грешки сте ок (0–1000, емоционално)", + "press_a_key": "Натисни клавиш", "view_options": "Вижте настройките", "toggle_view": "Превключване на изгледа", "toggle_view_desc": "Алтернативен изглед (терен/държави)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Пускане на водородна бомба под курсора Ви.", "build_mirv": "Пускане на МИРВ", "build_mirv_desc": "Пускане на МИРВ под курсора Ви.", + "menu_shortcuts": "Преки пътища за меню", + "build_menu_modifier": "Модификатор на менюто за изграждане", + "build_menu_modifier_desc": "Задръж този клавиш, докато кликаш, за да отвориш менюто за изграждане.", + "emoji_menu_modifier": "Модификатор на менюто с емоджита", + "emoji_menu_modifier_desc": "Задръж този клавиш, докато кликаш, за да отвориш менюто за емоджита.", "attack_ratio_controls": "Контроли за съотношение на атака", "attack_ratio_up": "Увеличаване на съотношение на атака", "attack_ratio_up_desc": "Увеличаване на съотношение на атака с 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Изпраща атака с лодка към плочката под курсора ви.", "ground_attack": "Земна атака", "ground_attack_desc": "Изпраща земна атака към плочката под курсора ви.", + "swap_direction": "Размени посоката на ракетата", + "swap_direction_desc": "Превключване на посоката на изстрелване на ракетата (нагоре/надолу).", "zoom_controls": "Контроли за позиция на камерата", "zoom_out": "Отдалечаване на камерата", "zoom_out_desc": "Отдалечаване на камерата от картата", @@ -416,7 +526,8 @@ "exit_game_label": "Напускане на играта", "exit_game_info": "Връщане в главното меню", "background_music_volume": "Сила на фоновата музика", - "sound_effects_volume": "Сила на звука на звуковите ефекти" + "sound_effects_volume": "Сила на звука на звуковите ефекти", + "keybind_conflict_error": "Клавишът {key} вече е вързан за друго действие." }, "chat": { "title": "Бърз чат", @@ -529,6 +640,7 @@ "other_team": "{team} отбор спечели!", "you_won": "Ти спечели!", "other_won": "{player} спечели!", + "nation_won": "Нацията {nation} спечели!", "exit": "Напускане на играта", "keep": "Продължаване на играта", "spectate": "Наблюдаване", @@ -537,7 +649,7 @@ "ofm_winter_description": "Присъедини се към състезателния турнир и се състезавай срещу най-добрите играчи", "join_tournament": "Присъедини се към турнира", "join_discord": "Присъедини се към общността ни в Discord!", - "discord_description": "Свържи се с други играчи, получавай актуална информация и споделяй стратегии", + "discord_description": "Свържи се с играчи, открий нови функции и спечели награди!", "join_server": "Влез в сървъра", "youtube_tutorial": "Нужда от помощ?" }, @@ -549,7 +661,7 @@ "team": "Отбор", "owned": "Притежавано", "gold": "Злато", - "troops": "Войници", + "maxtroops": "Максимални войници", "launchers": "Установки", "sams": "Противоракетни установки земя-въздух SAM", "warships": "Бойни кораби", @@ -565,6 +677,7 @@ "team": "Отбор", "alliance_timeout": "Съюзът изтича след", "troops": "Войници", + "maxtroops": "Максимални войници", "a_troops": "Атакуващи войници", "gold": "Злато", "ports": "Пристанища", @@ -575,7 +688,9 @@ "warships": "Бойни кораби", "health": "Живот", "attitude": "Становище", - "levels": "Нива" + "levels": "Нива", + "wilderness_title": "Пустош", + "irradiated_wilderness_title": "Облъчена пустош" }, "events_display": { "retreating": "отстъпване", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} иска да поднови съюза си с теб", "ignore": "Игнориране", "unit_voluntarily_deleted": "Елементът бе изтрит доброволно", - "betrayal_debuff_ends": "Остават {time} секунди до края на предателското отслабване" + "betrayal_debuff_ends": "Остават {time} секунди до края на предателското отслабване", + "attack_cancelled_retreat": "Атаката бе отменена, {troops} войници бяха убити по време на отстъплението", + "received_gold_from_captured_ship": "Получи {gold} злато от лодка на {name}, превзета от теб", + "received_gold_from_trade": "Получи {gold} злато от търговия с {name}", + "missile_intercepted": "Противоракетната установка прихвана {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} МИРВ бе прихванат} other {{count} МИРВ-а бяха прихванати}}", + "sent_troops_to_player": "Изпрати {troops} войници на {name}", + "received_troops_from_player": "Получи {troops} войници от {name}", + "sent_gold_to_player": "Изпрати {gold} злато на {name}", + "received_gold_from_player": "Получи {gold} злато от {name}", + "unit_captured_by_enemy": "Твоят/а {unit} бе превзет от {name}", + "captured_enemy_unit": "Превзе {unit} от {name}", + "unit_destroyed": "Твоят/а {unit} бе унищожен", + "no_boats_available": "Няма свободни кораби, максимум {max}" }, "unit_info_modal": { "structure_info": "Информация за постройката", @@ -653,7 +781,10 @@ "send_alliance": "Изпрати съюз", "send_troops": "Изпрати войници", "send_gold": "Изпрати злато", - "emotes": "Емоджита" + "emotes": "Емоджита", + "arc_up": "Възходяща дъга", + "arc_down": "Низходяща дъга", + "flip_rocket_trajectory": "Обърни траекторията на ракетата" }, "send_troops_modal": { "title_with_name": "Изпрати войници на {name}", @@ -702,25 +833,31 @@ }, "heads_up_message": { "choose_spawn": "Изберете начална локация", - "random_spawn": "Случайното появяване е активирано. Избиране на начална локация за теб..." + "random_spawn": "Случайното появяване е активирано. Избиране на начална локация за теб...", + "singleplayer_game_paused": "Играта е на пауза", + "multiplayer_game_paused": "Играта е паузирана от създателя на лобито" }, "territory_patterns": { "title": "Териториални шаблони", "colors": "Цветове", "purchase": "Купуване", "show_only_owned": "Моите шаблони", + "all_owned": "Имаш всички шаблони! Провери отново по-късно за нови артикули.", + "not_logged_in": "Не си се вписал в профил", "blocked": { "login": "Трябва да сте влезли в профила си, за да получите достъп до този шаблон.", "purchase": "Закупете този шаблон, за да го отключите." }, "pattern": { "default": "Стандартен" - } + }, + "select_skin": "Избери шаблон", + "selected": "е избран" }, "flag_input": { - "title": "Изберете знаме", - "button_title": "Изберете знаме!", - "search_flag": "Търсене..." + "title": "Избери знаме", + "button_title": "Избери знаме!", + "search_flag": "Търси..." }, "spawn_ad": { "loading": "Зарежда се реклама..." @@ -786,8 +923,9 @@ "mode": "Вид", "mode_ffa": "Всеки срещу всеки (FFA)", "mode_team": "Отбор", - "view": "Виж", + "replay": "Повторение", "details": "Детайли", + "ranking": "Класиране", "started": "Стартирана", "map": "Карта", "difficulty": "Трудност", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Публична", "private": "Частна", - "singleplayer": "Самостоятелна Игра", + "singleplayer": "Самостоятелно", "mode": "Вид", "stats_wins": "Победи", "stats_losses": "Загуби", "stats_wlr": "Съотношение победи:загуби", "stats_games_played": "Изиграни игри", "mode_ffa": "Всеки срещу всеки (FFA)", - "mode_team": "Отбор" + "mode_team": "Отбор", + "no_stats": "Няма записани статистики за тази селекция." + }, + "matchmaking_button": { + "play_ranked": "1v1 Ранков мачмейкинг", + "description": "(АЛФА)", + "login_required": "Впиши се, за да играеш ранково!", + "must_login": "Трябва да си вписан в профила си, за да играеш ранков мачмейкинг." } } diff --git a/resources/lang/en.json b/resources/lang/en.json index 77c000f23..f8942ac59 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -7,6 +7,7 @@ }, "common": { "close": "Close", + "copy": "Copy", "back": "Back", "available": "Available", "preset_max": "Max", @@ -45,18 +46,50 @@ "play": "Play", "news": "News", "store": "Store", + "store_new_badge": "NEW", "settings": "Settings", "keys": "Keys", "stats": "Stats", "account": "Account", "help": "Help", "menu": "Menu", + "troubleshooting": "Troubleshooting", + "go_to_troubleshooting": "Go to our troubleshooting page", "pick_pattern": "Pick a pattern!" }, "news": { "github_link": "on GitHub", "title": "Release Notes" }, + "troubleshooting": { + "title": "Troubleshooting", + "loading": "Loading...", + "environment": "Environment", + "rendering": "Rendering", + "power": "Power", + "browser": "Browser", + "platform": "Platform", + "copied_to_clipboard": "Info copied to the clipboard! Feel free to share it on our Discord if you need help.", + "os": "OS", + "device_pixel_ratio": "Device Pixel Ratio", + "chromium_tip": "OpenFront runs best on Chromium-based browsers.", + "hardware_acceleration_tip": "Make sure hardware acceleration is enabled in your browser settings for optimal performance.", + "renderer": "Renderer", + "max_texture_size": "Max Texture Size", + "high_precision_shaders": "High Precision Shaders", + "gpu": "GPU", + "unavailable": "Unavailable", + "gpu_tip": "Verify that this is the dedicated GPU, if one is available.", + "battery": "Battery", + "charging": "Charging", + "battery_level": "Battery Level", + "power_saving_tip": "Make sure that your browser is not set to power saving mode.", + "yes": "Yes", + "no": "No", + "unknown": "Unknown", + "software_rendering": "Software rendering", + "canvas_2d_no_gpu": "Canvas 2D (no GPU)" + }, "help_modal": { "hotkeys": "Hotkeys", "table_key": "Key", @@ -141,6 +174,7 @@ "build_mirv": "MIRV", "build_mirv_desc": "The most powerful bomb in the game. Splits up into smaller bombs that will cover a huge range of territory. Only damages the player that you first clicked on to build it. Spawns from the nearest Missile Silo and lands in the area you first clicked to build it.", "player_icons": "Player icons", + "troubleshooting_desc": "If you experience performance issues, crashes, or other problems while playing OpenFront, please visit our Troubleshooting page for help diagnosing and fixing common issues:", "icon_desc": "Examples of some of the ingame icons you will encounter and what they mean:", "icon_crown": "Crown - Number 1. This is the top player in the leaderboard.", "icon_traitor": "Broken shield - Traitor. This player attacked an ally.", @@ -166,12 +200,17 @@ "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", "compact_map": "Compact Map", + "crowded": "Crowded", "max_timer": "Game length (minutes)", "max_timer_placeholder": "Mins", "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", - "start": "Start Game" + "start": "Start Game", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "token_login_modal": { "title": "Logging in...", @@ -194,7 +233,8 @@ "not_found": "Not Found", "clear_session": "Clear Session", "failed_to_send_recovery_email": "Failed to send recovery email", - "enter_email_address": "Please enter an email address" + "enter_email_address": "Please enter an email address", + "personal_player_id": "Personal Player ID:" }, "stats_modal": { "title": "Stats", @@ -224,9 +264,12 @@ "total_gold": "Total", "all_gold": "All gold", "trade": "Trade", + "train_trade": "Train", + "naval_trade": "Tradeship", "conquest_gold": "Conquered player gold", "stolen_gold": "Stolen with warships", - "num_of_conquests": "Number of conquered players", + "num_of_conquests_humans": "Player kills", + "num_of_conquests_bots": "Bot kills", "duration": "Duration", "survival_time": "Survival time", "war": "War", @@ -239,6 +282,8 @@ }, "map": { "map": "Map", + "featured": "Featured", + "all": "All", "world": "World", "giantworldmap": "Giant World Map", "europe": "Europe", @@ -289,6 +334,7 @@ "amazonriver": "Amazon River" }, "map_categories": { + "featured": "Featured", "continental": "Continental", "regional": "Regional", "fantasy": "Other", @@ -379,7 +425,11 @@ "teams_Duos": "Duos (teams of 2)", "teams_Trios": "Trios (teams of 3)", "teams_Quads": "Quads (teams of 4)", - "teams_Humans Vs Nations": "Humans vs Nations" + "teams_Humans Vs Nations": "Humans vs Nations", + "gold_multiplier": "Gold multiplier", + "gold_multiplier_placeholder": "2.0x", + "starting_gold": "Starting gold", + "starting_gold_placeholder": "5000000" }, "team_colors": { "red": "Red", @@ -409,7 +459,9 @@ }, "public_game_modifier": { "random_spawn": "Random Spawn", - "compact_map": "Compact Map" + "compact_map": "Compact Map", + "crowded": "Crowded", + "starting_gold": "5M Starting Gold" }, "select_lang": { "title": "Select Language" @@ -701,6 +753,8 @@ "alliance_request_status": "{name} {status} your alliance request", "alliance_accepted": "accepted", "alliance_rejected": "rejected", + "alliance_nukes_destroyed_outgoing": "{count, plural, one {# nuke launched toward {name} was destroyed due to the alliance} other {# nukes launched toward {name} were destroyed due to the alliance}}", + "alliance_nukes_destroyed_incoming": "{count, plural, one {# nuke launched by {name} was destroyed due to the alliance} other {# nukes launched by {name} were destroyed due to the alliance}}", "duration_second": "1 second", "betrayal_description": "You broke your alliance with {name}, making you a TRAITOR ({malusPercent}% defense debuff for {durationText})", "duration_seconds_plural": "{seconds} seconds", @@ -784,10 +838,18 @@ "send_troops": "Send Troops", "send_gold": "Send Gold", "emotes": "Emojis", + "moderation": "Moderation", + "kick": "Kick player", + "kicked": "Already kicked", + "kick_confirm": "Kick {name}?\n\nThey won't be able to rejoin this game.", "arc_up": "Upward arc", "arc_down": "Downward arc", "flip_rocket_trajectory": "Flip rocket trajectory" }, + "kick_reason": { + "duplicate_session": "Kicked from game (you may have been playing on another tab)", + "lobby_creator": "Kicked by lobby creator" + }, "send_troops_modal": { "title_with_name": "Send Troops to {name}", "available_tooltip": "Your current available troops", diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 31328191e..0df822e4e 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -7,6 +7,7 @@ }, "common": { "close": "Fermer", + "back": "Retour", "available": "Disponible", "preset_max": "Max", "summary_send": "Envoyer", @@ -17,26 +18,42 @@ "cap_tooltip": "Capacité restante du destinataire", "target_dead": "Cible éliminée", "target_dead_note": "Vous ne pouvez pas envoyer de ressources à un joueur éliminé.", - "none": "Aucun" + "none": "Aucun", + "copied": "Copié !", + "click_to_copy": "Cliquer pour copier" }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Discord", "login_discord": "Se connecter avec Discord", + "sign_in": "Se connecter", + "discord_avatar_alt": "Avatar du profil Discord", + "user_avatar_alt": "Avatar de {username}", "checking_login": "Vérification de la connexion...", "logged_in": "Connecté !", "log_out": "Se déconnecter", - "create_lobby": "Créer un salon", - "join_lobby": "Rejoindre un salon", - "single_player": "Mode solo", + "create": "Créer un salon", + "join": "Rejoindre un salon", + "solo": "Solo", "instructions": "Instructions", + "game_info": "Infos sur la partie", "wiki": "Wiki", "privacy_policy": "Politique de confidentialité", "terms_of_service": "Conditions d'utilisation", - "reddit": "Reddit" + "copyright": "©️ OpenFront™ et Contributeurs", + "reddit": "Reddit", + "play": "Jouer", + "news": "Actus", + "store": "Boutique", + "settings": "Paramètres", + "keys": "Touches", + "stats": "Stats", + "account": "Compte", + "help": "Aide", + "menu": "Menu", + "pick_pattern": "Choisis un motif !" }, "news": { - "see_all_releases": "Voir toutes les versions", "github_link": "sur Github", "title": "Notes de version" }, @@ -67,7 +84,7 @@ "ui_events_desc": "Le panneau des événements affiche les derniers événements, demandes et messages de chat rapide. Quelques exemples sont :", "ui_events_alliance": "Alliance - Les demandes d'Alliance peuvent être acceptées ou rejetées. Les alliés peuvent partager des ressources et des troupes, mais ne peuvent pas s'attaquer. Cliquer sur Focus déplace la vue vers le joueur qui a envoyé la requête.", "ui_events_attack": "Attaques - Les attaques entrantes et sortantes sont affichées. Cliquez sur le message pour centrer la vue sur l'attaque, la bombe ou le bateau (navire de transport). Vous pouvez envoyer les troupes en retraite en cliquant sur le bouton rouge X. Cela coûtera la vie à 25% de vos troupes attaquantes. Si vous annulez une attaque de bateau, le bateau revient à son point de départ et y attaquera si la terre a été capturée depuis. Les bombes atomiques ne peuvent pas être retirées une fois lancées.", - "ui_events_quickchat": "Chat rapide - Vous pouvez voir les messages envoyés et reçus ici. Envoyez un message à un joueur en cliquant sur l'icône Chat rapide dans son menu Infos.", + "ui_events_quickchat": "Chat Rapide - Vous pouvez voir les messages envoyés et reçus ici. Envoyez un message à un joueur en cliquant sur l'icône Chat Rapide dans son menu Infos.", "ui_options": "Options", "ui_options_desc": "Les éléments suivants peuvent être trouvés à l'intérieur :", "ui_playeroverlay": "Informations sur le joueur", @@ -83,6 +100,8 @@ "radial_attack": "Ouvrez le menu d'attaque.", "radial_info": "Ouvrir le menu d'informations.", "radial_boat": "Envoyer un bateau (navire de transport) pour attaquer l'endroit sélectionné. Disponible uniquement si vous avez accès à l'eau.", + "radial_donate_troops": "Donner des troupes équivalent à votre ratio d'attaque à l'allié sur lequel vous avez ouvert le menu radial.", + "radial_donate_gold": "Ouvre le menu du curseur de don d'or pour que vous puissiez envoyer rapidement de l'or aux alliés.", "radial_close": "Fermer le menu.", "info_title": "Menu d'informations", "info_enemy_desc": "Contient des informations telles que le nom du joueur sélectionné, son or, ses troupes, s'il a cessé de commercer avec vous, les bombes qu'il vous a envoyées, et si le joueur est un traître. L'arrêt du commerce signifie que vous ne recevrez pas d'or de sa part et qu'il ne vous enverra pas d'or via des navires commerciaux. Manuellement (si le joueur a cliqué sur « Arrêter le commerce », qui dure jusqu'à ce que vous cliquiez sur « Commencer le commerce ») ou automatiquement (si vous avez trahi votre alliance, ce qui dure jusqu'à ce que vous deveniez alliés de nouveau ou après 5 minutes). Le traître affiche Oui pendant 30 secondes lorsque le joueur a trahi et attaqué un joueur qui était dans une alliance avec lui. Les icônes ci-dessous représentent les interactions suivantes :", @@ -114,7 +133,7 @@ "build_silo": "Silo à missiles", "build_silo_desc": "Permet de lancer des missiles.", "build_sam": "Lanceur SAM", - "build_sam_desc": "Vous pouvez intercepter les missiles ennemis à portée de 100 pixels. Avec une probabilité de 100% pour la Bombe Atomique, 80% pour la Bombe Hydrogène et 50% pour les Ogives MIRV individuelles. Le SAM a un temps de recharge de 7,5 secondes.", + "build_sam_desc": "Peut intercepter les missiles ennemis dans un rayon de 100 pixels. Le SAM a un temps de recharge de 7,5 secondes.", "build_atom": "Bombe atomique", "build_atom_desc": "Petite bombe explosive qui détruit le territoire, les bâtiments, les navires et les bateaux. Apparaît depuis le Silo à missiles le plus proche et atterrit dans la zone cliquée.", "build_hydrogen": "Bombe à hydrogène", @@ -129,12 +148,15 @@ "icon_embargo": "Signe dollar barré - Embargo. Ce joueur a cessé de commercer avec vous automatiquement ou manuellement.", "icon_request": "Enveloppe - Demande d'alliance. Ce joueur vous a envoyé une demande d'alliance.", "info_enemy_panel": "Panneau d'information de l'ennemi", - "exit_confirmation": "Êtes-vous sûr de vouloir quitter le jeu ?" + "exit_confirmation": "Êtes-vous sûr de vouloir quitter le jeu ?", + "bomb_direction": "Direction de l'arc de bombe Atomique / Hydrogène" }, "single_modal": { - "title": "Joueur seul", + "title": "Solo", "random_spawn": "Spawn aléatoire", "allow_alliances": "Autoriser les alliances", + "toggle_achievements": "Activer / Désactiver les succès", + "sign_in_for_achievements": "Connectez-vous pour obtenir des succès", "options_title": "Options", "bots": "Bots : ", "bots_disabled": "Désactivé", @@ -145,6 +167,8 @@ "infinite_troops": "Troupes infinies", "compact_map": "Carte compacte", "max_timer": "Durée de jeu (minutes)", + "max_timer_placeholder": "Mins", + "max_timer_invalid": "Veuillez entrer une valeur max valide pour le minuteur (1-120 minutes)", "disable_nukes": "Désactiver les armes nucléaires", "enables_title": "Activer les paramètres", "start": "Commencer la partie" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Compte", - "logged_in_as": "Connecté en tant que {email}", + "connected_as": "Connecté en tant que", + "stats_overview": "Aperçu des Statistiques", + "link_discord": "Lier un Compte Discord", + "log_out": "Se Déconnecter", + "sign_in_desc": "Connectez-vous pour enregistrer vos statistiques et progrès", + "or": "OU", + "email_placeholder": "Entrez votre adresse email", + "get_magic_link": "Obtenir un Lien Magique", + "linked_account": "Connecté en tant que {account_name}", "fetching_account": "Récupération des informations du compte...", - "logged_in_with_discord": "Connecté avec Discord", - "recovery_email_sent": "Courriel de récupération envoyé à {email}" + "recovery_email_sent": "Courriel de récupération envoyé à {email}", + "not_found": "Introuvable", + "clear_session": "Effacer la session", + "failed_to_send_recovery_email": "Échec de l'envoi de l'e-mail de récupération", + "enter_email_address": "Veuillez saisir une adresse e-mail" }, "stats_modal": { "title": "Statistiques", @@ -167,11 +202,40 @@ "loading": "Chargement...", "error": "Erreur lors du chargement des statistiques du clan", "no_stats": "Pas de statistique de clan disponible", + "no_data_yet": "Aucune donnée pour le moment", "clan": "Clan", "games": "Parties", "win_score": "Score de Victoire", + "win_score_tooltip": "Victoires pondérées en fonction de la participation du clan et de la difficulté du match", "loss_score": "Score de Défaite", - "win_loss_ratio": "Victoires/Défaites" + "loss_score_tooltip": "Défaites pondérées en fonction de la participation du clan et de la difficulté du match", + "win_loss_ratio": "Victoires/Défaites", + "ratio": "Coefficient", + "rank": "Rang", + "try_again": "Réessayer" + }, + "game_info_modal": { + "title": "Infos sur la partie", + "players": "Joueurs", + "atoms": "Atomes", + "hydros": "Hydros", + "mirv": "MIRV", + "bombs": "Bombes", + "total_gold": "Total", + "all_gold": "Tout l'or", + "trade": "Commercer", + "conquest_gold": "Or de joueur conquis", + "stolen_gold": "Volé avec des navires de guerre", + "num_of_conquests": "Nombre de joueurs conquis", + "duration": "Durée", + "survival_time": "Temps de survie", + "war": "Guerre", + "economy": "Économie", + "conquests": "Conquêtes", + "pirate": "Pirate", + "conquered": "Conquis", + "loading_game_info": "Chargement des stats du jeu", + "no_winner": "Cette partie s'est terminée sans aucun gagnant (ou une Nation a gagné)" }, "map": { "map": "Carte", @@ -186,6 +250,7 @@ "asia": "Asie", "mars": "Mars", "southamerica": "Amérique du Sud", + "britanniaclassic": "Grande-Bretagne (Classique)", "britannia": "Grande-Bretagne", "gatewaytotheatlantic": "Porte de l'Atlantique", "australia": "Australie", @@ -196,7 +261,7 @@ "betweentwoseas": "Entre deux mers", "faroeislands": "Îles Féroé", "deglaciatedantarctica": "Antarctique Déglacée", - "europeclassic": "Europe (classique)", + "europeclassic": "Europe (Classique)", "falklandislands": "Îles Malouines", "baikal": "Lac Baïkal", "halkidiki": "Chalcidique", @@ -206,19 +271,33 @@ "yenisei": "Ienisseï", "pluto": "Pluton", "montreal": "Montréal", + "newyorkcity": "New York City", "achiran": "Achiran", "baikalnukewars": "Baïkal (Guerres Nucléaires)", "fourislands": "Quatre Îles", "gulfofstlawrence": "Golfe du Saint-Laurent", - "lisbon": "Lisbonne" + "lisbon": "Lisbonne", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "Deux Lacs", + "straitofhormuz": "Détroit d'Ormuz", + "surrounded": "Encerclé", + "didier": "Didier", + "didierfrance": "Didier (France)", + "amazonriver": "Fleuve Amazone" }, "map_categories": { "continental": "Continental", "regional": "Régional", - "fantasy": "Autre" + "fantasy": "Autre", + "special": "Spéciales", + "arcade": "Arcade" }, "map_component": { - "loading": "Chargement..." + "loading": "Chargement...", + "error": "Erreur" }, "private_lobby": { "title": "Rejoindre un salon privé", @@ -229,42 +308,55 @@ "checking": "Vérification du salon...", "not_found": "Salon introuvable. Veuillez vérifier l'ID et réessayer.", "error": "Une erreur s'est produite. Veuillez réessayer ou contacter le support.", - "joined_waiting": "Rejoint avec succès ! En attente du début de la partie...", - "version_mismatch": "Cette partie a été créée avec une version différente. Impossible de rejoindre." + "joined_waiting": "Salon rejoint ! En attente du démarrage de l'hôte...", + "version_mismatch": "Cette partie a été créée avec une version différente. Impossible de rejoindre.", + "disabled_units": "Unités désactivées" }, "public_lobby": { "join": "Rejoindre la prochaine partie", "waiting": "joueurs en attente", - "teams_Duos": "de 2 (Duos)", - "teams_Trios": "de 3 (Trios)", - "teams_Quads": "de 4 (Quatuors)", - "teams_hvn": "Humains Vs Nations", + "teams_Duos": "{team_count} équipes de 2 (Duos)", + "teams_Trios": "{team_count} équipes de 3 (Trios)", + "teams_Quads": "{team_count} équipes de 4 (Quatuors)", + "waiting_for_players": "En attente de joueurs", + "starting_game": "Démarrage en cours...", + "teams_hvn": "Humains vs Nations", + "teams_hvn_detailed": "{num} Humains vs {num} Nations", "teams": "{num} équipes", - "players_per_team": "de {num}" + "players_per_team": "de {num}", + "started": "Lancé" }, "matchmaking_modal": { - "title": "Matchmaking", + "title": "Matchmaking 1v1 Classé (ALPHA)", "connecting": "Connexion au serveur de matchmaking...", "searching": "Recherche d'une partie...", - "waiting_for_game": "En attente du début de la partie..." + "waiting_for_game": "En attente du début de la partie...", + "elo": "Votre ELO : {elo}" }, "username": { "enter_username": "Entrez votre nom d'utilisateur", "not_string": "Le nom d'utilisateur doit être une chaîne de caractères.", "too_short": "Le nom d'utilisateur doit comporter au moins {min} caractères.", "too_long": "Le nom d'utilisateur ne doit pas dépasser {max} caractères.", - "invalid_chars": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres, des espaces, des tirets bas et des [crochets]." + "invalid_chars": "Le pseudonyme peut seulement contenir des lettres, chiffres, espaces et underscores.", + "tag": "TAG", + "tag_too_short": "Le tag de clan doit faire 2 à 5 caractères alphanumériques.", + "tag_invalid_chars": "Le tag de clan peut seulement contenir des lettres et des chiffres." }, "host_modal": { - "title": "Salon privé", + "title": "Créer un Salon Privé", + "label": "Privé", "mode": "Mode", "team_count": "Nombre d'équipes", + "team_type": "Type d'équipe", "options_title": "Paramètres", "bots": "Bots : ", "bots_disabled": "Désactivé", + "player_immunity_duration": "Immunité au JcJ (minutes)", "nations": "Nations : ", "disable_nations": "Désactiver les nations", "max_timer": "Durée de jeu (minutes)", + "mins_placeholder": "Mins", "instant_build": "Construction instantanée", "infinite_gold": "Or infini", "donate_gold": "Donner de l'or", @@ -283,7 +375,11 @@ "assigned_teams": "Équipes Attribuées", "empty_teams": "Équipes vides", "empty_team": "Vide", - "remove_player": "Retirer {username}" + "remove_player": "Retirer {username}", + "teams_Duos": "Duos (équipes de 2)", + "teams_Trios": "Trios (équipes de 3)", + "teams_Quads": "Quatuors (équipes de 4)", + "teams_Humans Vs Nations": "Humains vs Nations" }, "team_colors": { "red": "Rouge", @@ -301,16 +397,20 @@ "code_license": "Code sous licence AGPL-3.0 (sans garantie)" }, "difficulty": { - "difficulty": "Difficulté", - "Easy": "Détendu", - "Medium": "Équilibré", - "Hard": "Intense", - "Impossible": "Impossible" + "difficulty": "Difficulté des nations", + "easy": "Facile", + "medium": "Moyen", + "hard": "Difficile", + "impossible": "Impossible" }, "game_mode": { "ffa": "Chacun pour soi", "teams": "Équipes" }, + "public_game_modifier": { + "random_spawn": "Spawn aléatoire", + "compact_map": "Carte compacte" + }, "select_lang": { "title": "Sélectionner une langue" }, @@ -327,7 +427,7 @@ "factory": "Usine" }, "user_setting": { - "title": "Paramètres utilisateur", + "title": "Paramètres", "tab_basic": "Réglages de base", "tab_keybinds": "Raccourcis clavier", "dark_mode_label": "Mode sombre", @@ -340,16 +440,18 @@ "special_effects_desc": "Activer/désactiver les effets spéciaux. Désactiver pour améliorer les performances", "structure_sprites_label": "Sprites de structure", "structure_sprites_desc": "Activer/désactiver les sprites de structure", + "cursor_cost_label_label": "Coûts de construction", + "cursor_cost_label_desc": "Afficher une pastille indiquant le coût sous l'icône du curseur de construction", "anonymous_names_label": "Noms masqués", "anonymous_names_desc": "Cacher le vrai nom des joueurs avec des noms aléatoires sur votre écran.", "lobby_id_visibility_label": "ID du salon masqué", "lobby_id_visibility_desc": "Cacher l'ID du salon lors de la création du salon privé", + "toggle_visibility": "Changer la visibilité", "left_click_label": "Clic gauche pour ouvrir le menu", "left_click_desc": "Activé, un clic gauche ouvre le menu et le bouton épée d'attaque. Désactivé, un clic gauche attaque directement.", "left_click_menu": "Menu Clic gauche", "attack_ratio_label": "⚔️ Ratio d'attaque", "attack_ratio_desc": "Quel pourcentage de vos troupes envoyer dans une attaque (1–100%)", - "troop_ratio_desc": "Ajuster l'équilibre entre les troupes (pour le combat) et les ouvriers (pour la production d'or) (1–100%)", "territory_patterns_label": "🏳️ Skins de territoire", "territory_patterns_desc": "Choisissez d'afficher ou non les designs des skins de territoire dans le jeu", "performance_overlay_label": "Surcouche de performances", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "Ajuster la vitesse à laquelle vous prétendez coder (x1–x100)", "easter_bug_count_label": "Nombre de bugs", "easter_bug_count_desc": "Combien de bugs vous acceptez (0-1000, émotionnellement)", + "press_a_key": "Appuyez sur une touche", "view_options": "Options d'affichage", "toggle_view": "Activer/désactiver l'affichage", "toggle_view_desc": "Vue alternative (terrain/pays)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Envoyer une bombe à hydrogène sous votre curseur.", "build_mirv": "Construire un MIRV", "build_mirv_desc": "Construire un MIRV sous votre curseur.", + "menu_shortcuts": "Menu des raccourcis ", + "build_menu_modifier": "Menu de modification des constructions", + "build_menu_modifier_desc": "Maintenez cette touche enfoncée en cliquant pour ouvrir le menu de construction.", + "emoji_menu_modifier": "Menu de modifications des émojis", + "emoji_menu_modifier_desc": "Maintenez cette touche enfoncée en cliquant pour ouvrir le menu des emojis.", "attack_ratio_controls": "Contrôles du ratio d'attaque", "attack_ratio_up": "Augmenter le ratio d'attaque", "attack_ratio_up_desc": "Augmenter le ratio d'attaque de 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Envoyer une attaque navale à la tuile sous votre curseur.", "ground_attack": "Attaque au sol", "ground_attack_desc": "Envoyez une attaque au sol sur la tuile sous votre curseur.", + "swap_direction": "Inverser la trajectoire balistique", + "swap_direction_desc": "Inverser la trajectoire balistique de lancement", "zoom_controls": "Contrôles de zoom", "zoom_out": "Zoom arrière", "zoom_out_desc": "Dézoom de la carte", @@ -416,7 +526,8 @@ "exit_game_label": "Quitter la partie", "exit_game_info": "Retour au menu principal", "background_music_volume": "Volume de la musique de fond", - "sound_effects_volume": "Volume des effets sonores" + "sound_effects_volume": "Volume des effets sonores", + "keybind_conflict_error": "La clé {key} est déjà liée à une autre action." }, "chat": { "title": "Discussion", @@ -529,6 +640,7 @@ "other_team": "L'équipe {team} a gagné !", "you_won": "Vous avez gagné !", "other_won": "{player} a gagné !", + "nation_won": "La nation {nation} a gagné !", "exit": "Quitter la partie", "keep": "Continuer à jouer", "spectate": "Regarder", @@ -537,7 +649,7 @@ "ofm_winter_description": "Rejoignez le tournoi et affrontez les meilleurs joueurs", "join_tournament": "Rejoindre le tournoi", "join_discord": "Rejoignez notre communauté Discord !", - "discord_description": "Connectez-vous avec d'autres joueurs, recevez les nouvelles et partagez des stratégies", + "discord_description": "Parlez avec des joueurs, découvrez de nouvelles fonctionnalités et gagnez des prix !", "join_server": "Rejoindre le Serveur", "youtube_tutorial": "Besoin d'aide ?" }, @@ -549,7 +661,7 @@ "team": "Équipe", "owned": "Possédé", "gold": "Or", - "troops": "Troupes", + "maxtroops": "Troupes max", "launchers": "Lanceurs", "sams": "SAMs", "warships": "Vaisseaux de guerre", @@ -565,6 +677,7 @@ "team": "Équipe", "alliance_timeout": "L'alliance se termine dans", "troops": "Troupes", + "maxtroops": "Troupes max", "a_troops": "Troupes en attaque", "gold": "Or", "ports": "Ports", @@ -575,7 +688,9 @@ "warships": "Navires de guerre", "health": "Santé", "attitude": "Attitude", - "levels": "Niveaux" + "levels": "Niveaux", + "wilderness_title": "Étendues sauvages", + "irradiated_wilderness_title": "Terre irradiée" }, "events_display": { "retreating": "en retraite", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} souhaite renouveler votre alliance", "ignore": "Ignorer", "unit_voluntarily_deleted": "Unité volontairement supprimée", - "betrayal_debuff_ends": "{time} secondes restantes jusqu'à la fin du malus de trahison" + "betrayal_debuff_ends": "{time} secondes restantes jusqu'à la fin du malus de trahison", + "attack_cancelled_retreat": "Attaque annulée, {troops} soldats ont été tués pendant la retraite", + "received_gold_from_captured_ship": "{gold} ors reçu pour la capture d'un navire de {name}", + "received_gold_from_trade": "{gold} ors reçu pour le commerce avec {name}", + "missile_intercepted": "Le missile a intercepté {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} ogive nucléaire MIRV a été interceptée} other {{count} ogives nucléaire MIRV ont été interceptées}}", + "sent_troops_to_player": "Vous avez envoyé {troops} troupes à {name}", + "received_troops_from_player": "Vous avez reçu {troops} troupes de {name}", + "sent_gold_to_player": "Vous avez envoyé {gold} ors à {name}", + "received_gold_from_player": "Vous avez reçu {gold} ors de {name}", + "unit_captured_by_enemy": "Votre {unit} a été capturé par {name}", + "captured_enemy_unit": "{unit} de {name} capturé", + "unit_destroyed": "Votre {unit} a été détruit", + "no_boats_available": "Aucun bateau disponible, max {max}" }, "unit_info_modal": { "structure_info": "Infos sur la structure", @@ -653,7 +781,10 @@ "send_alliance": "Envoyer une alliance", "send_troops": "Envoyer des troupes", "send_gold": "Envoyer de l'or", - "emotes": "Émojis" + "emotes": "Émojis", + "arc_up": "Arc vers le haut", + "arc_down": "Arc vers le bas", + "flip_rocket_trajectory": "Inverser la trajectoire de la fusée" }, "send_troops_modal": { "title_with_name": "Envoyer des troupes à {name}", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "Choisissez un emplacement de départ", - "random_spawn": "Le spawn aléatoire est activé. Sélection de l'emplacement de départ pour vous..." + "random_spawn": "Le spawn aléatoire est activé. Sélection de l'emplacement de départ pour vous...", + "singleplayer_game_paused": "Jeu en pause", + "multiplayer_game_paused": "Jeu mis en pause par le créateur du salon" }, "territory_patterns": { "title": "Skins", "colors": "Couleurs", "purchase": "Acheter", "show_only_owned": "Mes skins", + "all_owned": "Vous possédez déjà tous les motifs ! Revenez plus tard pour de nouveau.", + "not_logged_in": "Non connecté", "blocked": { "login": "Vous devez être connecté pour accéder à ce skin.", "purchase": "Achetez ce skin pour le débloquer." }, "pattern": { "default": "Par défaut" - } + }, + "select_skin": "Sélectionnez le motif", + "selected": "sélectionné" }, "flag_input": { "title": "Sélectionner un drapeau", @@ -786,8 +923,9 @@ "mode": "Mode", "mode_ffa": "Chacun pour soi", "mode_team": "Équipe", - "view": "Vue", + "replay": "Revoir", "details": "Détails", + "ranking": "Classé", "started": "Débuté", "map": "Carte", "difficulty": "Difficulté", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Public", "private": "Privé", - "singleplayer": "Mode solo", + "singleplayer": "Solo", "mode": "Mode", "stats_wins": "Victoires", "stats_losses": "Défaites", "stats_wlr": "Ratio Victoires:Défaites", "stats_games_played": "Parties jouées", "mode_ffa": "Chacun pour soi", - "mode_team": "En équipe" + "mode_team": "En équipe", + "no_stats": "Aucune statistique enregistrée pour cette sélection." + }, + "matchmaking_button": { + "play_ranked": "Matchmaking 1v1 classé", + "description": "(ALPHA)", + "login_required": "Connectez-vous pour jouer en mode classé", + "must_login": "Vous devez être connecté pour jouer en mode classé." } } diff --git a/resources/lang/id.json b/resources/lang/id.json new file mode 100644 index 000000000..7f7a55fc4 --- /dev/null +++ b/resources/lang/id.json @@ -0,0 +1,953 @@ +{ + "lang": { + "en": "Indonesian", + "native": "Bahasa Indonesia", + "svg": "id", + "lang_code": "id" + }, + "common": { + "close": "Keluar", + "back": "Kembali", + "available": "Tersedia", + "preset_max": "Maks", + "summary_send": "Kirim", + "summary_keep": "Simpan", + "cancel": "Batalkan", + "send": "Kirim", + "cap_label": "Batas maksimal", + "cap_tooltip": "Kapasitas penerima yang tersisa", + "target_dead": "Target dieliminasi", + "target_dead_note": "Anda tidak dapat mengirim sumber daya ke pemain yang telah tereliminasi.", + "none": "Tidak Satupun", + "copied": "Tersalin", + "click_to_copy": "Klik untuk salin" + }, + "main": { + "title": "OpenFront (ALPHA)", + "join_discord": "Discord", + "login_discord": "Masuk dengan Discord", + "sign_in": "Masuk", + "discord_avatar_alt": "Avatar profil Discord", + "user_avatar_alt": "Avatar {username}", + "checking_login": "Memeriksa login...", + "logged_in": "Berhasil masuk!", + "log_out": "Keluar", + "create": "Buat Lobi", + "join": "Bergabung ke Lobi", + "solo": "Sendiri", + "instructions": "Petunjuk", + "game_info": "Informasi Permainan", + "wiki": "Wiki", + "privacy_policy": "Kebijakan Privasi", + "terms_of_service": "Ketentuan Layanan", + "copyright": "© OpenFront™ dan para kontributor", + "reddit": "Reddit", + "play": "Main", + "news": "Berita", + "store": "Toko", + "settings": "Pengaturan", + "keys": "Tombol", + "stats": "Statistik", + "account": "Akun", + "help": "Bantuan", + "menu": "Menu", + "pick_pattern": "Pilih pola!" + }, + "news": { + "github_link": "di GitHub", + "title": "Catatan Rilis" + }, + "help_modal": { + "hotkeys": "Tombol pintas", + "table_key": "Kunci", + "table_action": "Tindakan", + "action_alt_view": "Ganti Tampilan (Medan / Negara)", + "action_attack_altclick": "Serang (saat klik kiri diatur untuk membuka menu)", + "action_build": "Buka menu Pembangunan", + "action_emote": "Buka menu Ekspresi", + "action_center": "Pusatkan kamera pada pemain", + "action_zoom": "Perkecil / Perbesar tampilan", + "action_move_camera": "Pindahkan kamera", + "action_ratio_change": "Kurangi / Tingkatkan rasio serangan", + "action_reset_gfx": "Atur ulang grafis", + "action_auto_upgrade": "Tingkatkan bangunan terdekat secara otomatis", + "ui_section": "UI Permainan", + "ui_leaderboard": "Papan Peringkat", + "ui_your_team": "Tim anda:", + "ui_leaderboard_desc": "Menampilkan pemain teratas dalam permainan beserta nama mereka, persentase wilayah yang dikuasai, jumlah emas, dan pasukan. Opsi Tampilkan Semua akan menampilkan seluruh pemain dalam permainan. Jika tidak ingin melihat papan peringkat, klik Sembunyikan.", + "ui_control": "Panel kendali", + "ui_control_desc": "Panel kontrol berisi elemen berikut:", + "ui_pop": "Populasi - Jumlah unit yang kamu miliki, batas populasi maksimum, serta laju pertambahannya.", + "ui_gold": "Emas - Jumlah emas yang kamu miliki dan laju perolehannya.", + "ui_attack_ratio": "Rasio Serangan - Jumlah pasukan yang akan digunakan saat kamu menyerang. Kamu dapat menyesuaikan rasio serangan menggunakan penggeser. Memiliki pasukan penyerang lebih banyak daripada pasukan bertahan akan mengurangi jumlah pasukan yang hilang saat menyerang, sedangkan jumlah pasukan yang lebih sedikit akan meningkatkan kerusakan yang diterima pasukan penyerang. Efek ini tidak berlaku di atas rasio 2:1.", + "ui_events": "Panel Event", + "ui_events_desc": "Panel Event menampilkan peristiwa, permintaan, dan pesan Obrolan Cepat terbaru. Beberapa contohnya adalah:", + "ui_events_alliance": "Aliansi - Permintaan aliansi dapat diterima atau ditolak. Sekutu dapat berbagi sumber daya dan pasukan, tetapi tidak dapat saling menyerang. Menekan Fokus akan memusatkan tampilan ke pemain yang mengirim permintaan.", + "ui_events_attack": "Serangan - Menampilkan serangan yang masuk dan serangan yang kamu lakukan. Klik pesan untuk memusatkan tampilan ke lokasi serangan, nuklir, atau Kapal (kapal pengangkut). Kamu dapat menarik mundur pasukan dengan menekan tombol X merah. Tindakan ini akan mengorbankan 25% dari pasukan penyerang.\nJika serangan Kapal ditarik kembali, kapal akan kembali ke titik awal dan akan menyerang kembali di sana jika wilayah tersebut telah dikuasai sejak saat itu. Serangan nuklir tidak dapat ditarik kembali setelah diluncurkan.", + "ui_events_quickchat": "Obrolan Cepat - Di sini kamu dapat melihat pesan obrolan yang dikirim dan diterima. Untuk mengirim pesan ke pemain, klik ikon Obrolan Cepat di menu Info pemain tersebut.", + "ui_options": "Pilihan", + "ui_options_desc": "Elemen-elemen berikut dapat ditemukan di dalamnya:", + "ui_playeroverlay": "Overlay Info Pemain", + "ui_playeroverlay_desc": "Saat kamu mengarahkan kursor ke suatu negara, overlay Info Pemain akan ditampilkan di bawah menu Opsi. Overlay ini menampilkan jenis pemain: Manusia, Negara (bot pintar), atau Bot; sikap suatu Negara terhadapmu, mulai dari Bermusuhan hingga Ramah; serta jumlah pasukan bertahan, emas, jumlah Kapal Perang, dan berbagai bangunan yang dimiliki pemain tersebut.", + "ui_wilderness": "Alam Liar", + "option_pause": "Jeda / Lanjutkan permainan – Hanya tersedia dalam mode single-player.", + "option_timer": "Timer – Waktu yang telah berlalu sejak permainan dimulai.", + "option_exit": "Tombol keluar.", + "option_settings": "Pengaturan – Membuka menu pengaturan. Di dalamnya kamu dapat mengaktifkan atau menonaktifkan Tampilan Alternatif, Emoji, Mode Gelap, Ninja (mode anonim / nama acak), serta aksi pada klik kiri.", + "radial_title": "Menu Radial", + "radial_desc": "Klik kanan (atau sentuhan di perangkat seluler) akan membuka Menu Radial. Klik kanan di luar menu untuk menutupnya. Dari menu ini kamu dapat:", + "radial_build": "Buka menu Pembangunan.", + "radial_attack": "Buka menu Serangan.", + "radial_info": "Buka menu informasi.", + "radial_boat": "Kirim Kapal (kapal pengangkut) untuk menyerang lokasi yang dipilih. Hanya tersedia jika kamu memiliki akses ke perairan.", + "radial_donate_troops": "Donasikan pasukan kepada sekutu sesuai dengan persentase pada penggeser rasio serangan yang sedang kamu gunakan pada menu radial tersebut.", + "radial_donate_gold": "Membuka menu penggeser donasi emas sehingga kamu dapat dengan cepat mengirim emas kepada sekutu.", + "radial_close": "Tutup menu.", + "info_title": "Menu Informasi", + "info_enemy_desc": "Berisi informasi seperti nama pemain yang dipilih, jumlah emas, pasukan, status berhenti berdagang dengan kamu, nuklir yang dikirim ke arahmu, serta apakah pemain tersebut adalah pengkhianat.\nStatus Berhenti Berdagang berarti kamu tidak akan menerima emas dari pemain tersebut dan mereka juga tidak akan mengirimkan emas kepadamu melalui kapal dagang. Status ini dapat terjadi secara manual (jika pemain menekan tombol “Hentikan Perdagangan”, yang akan berlangsung sampai kalian berdua menekan “Mulai Perdagangan”) atau secara otomatis (jika kamu mengkhianati aliansi, yang akan berlangsung sampai kalian kembali menjadi sekutu atau setelah 5 menit).\nStatus Pengkhianat akan menampilkan “Ya” selama 30 detik ketika pemain tersebut mengkhianati dan menyerang pemain yang sebelumnya berada dalam aliansi dengannya.\nIkon-ikon di bawah ini mewakili interaksi berikut:", + "info_chat": "Kirim pesan Obrolan Cepat ke pemain. Pilih Kategori, Frasa, dan jika frasa berisi [P1], pilih nama pemain untuk menggantikannya. Lalu tekan Kirim.", + "info_target": "Pasang tanda target pada pemain, sehingga terlihat oleh semua sekutu. Digunakan untuk mengoordinasikan serangan.", + "info_alliance": "Kirim permintaan aliansi ke pemain. Sekutu dapat berbagi sumber daya dan pasukan, tetapi tidak dapat saling menyerang.", + "info_emoji": "Kirim emoji ke pemainnya.", + "info_trade": "Gunakan “Hentikan Perdagangan” untuk berhenti memberikan emas kepada pemain tersebut dan berhenti menerima emas dari mereka melalui kapal dagang. Jika kalian berdua menekan “Mulai\".", + "info_ally_panel": "Panel Info Sekutu", + "info_ally_desc": "Saat kamu beraliansi dengan seorang pemain, ikon-ikon baru berikut akan tersedia:", + "ally_betray": "Mengkhianati sekutumu akan mengakhiri aliansi, menghentikan perdagangan, dan melemahkan pertahananmu. Perdagangan di antara kalian akan dijeda selama 5 menit (atau sampai kalian kembali menjadi sekutu), dan pemain lain juga dapat menghentikan perdagangan. Kecuali jika pemain lain tersebut memang sudah berstatus pengkhianat, kamu akan ditandai sebagai Pengkhianat selama 30 detik.\nSelama waktu ini, sebuah ikon akan muncul di atas namamu dan kamu akan menerima debuff pertahanan sebesar 50%. Bot akan lebih enggan beraliansi denganmu, dan pemain lain akan berpikir dua kali sebelum melakukannya.", + "ally_donate": "Donasikan sebagian pasukanmu kepada sekutu. Digunakan ketika mereka kekurangan pasukan, sedang diserang, atau membutuhkan kekuatan tambahan untuk menghancurkan musuh.", + "ally_donate_gold": "Donasikan sebagian emasmu kepada sekutu. Digunakan saat mereka kekurangan emas dan membutuhkannya untuk membangun, atau ketika anggota tim sedang menabung untuk MIRV.", + "build_menu_title": "Menu Pembangunan", + "build_menu_desc": "Bangun item berikut atau lihat jumlah yang sudah kamu bangun:", + "build_name": "Judul", + "build_icon": "Ikon", + "build_desc": "Deskripsi", + "build_city": "Kota", + "build_city_desc": "Meningkatkan batas populasi maksimum. Berguna saat kamu tidak dapat memperluas wilayah atau hampir mencapai batas populasi.", + "build_factory": "Pabrik", + "build_factory_desc": "Secara otomatis membangun jalur kereta api ke kota, pelabuhan, dan pabrik lain di sekitarnya, serta dapat terhubung dengan negara tetangga yang bersahabat. Kereta akan muncul secara berkala dan memberimu sejumlah emas tetap untuk setiap bangunan yang dikunjungi sepanjang rute, dengan bonus emas tambahan saat mengunjungi bangunan milik tetanggamu.", + "build_defense": "Pos Pertahanan", + "build_defense_desc": "Meningkatkan pertahanan di sekitar perbatasan terdekat, yang ditandai dengan pola kotak-kotak. Serangan musuh menjadi lebih lambat dan menyebabkan lebih banyak korban.", + "build_port": "Pelabuhan", + "build_port_desc": "Hanya dapat dibangun di dekat air. Memungkinkan pembangunan Kapal Perang. Secara otomatis mengirim kapal dagang antara pelabuhan di negaramu dan negara lain (kecuali saat perdagangan dihentikan), yang memberikan emas bagi kedua pihak.\nPerdagangan dengan seorang pemain akan berhenti secara otomatis ketika kamu menyerang atau diserang oleh pemain tersebut. Perdagangan akan dilanjutkan kembali setelah 5 menit atau jika kalian menjadi sekutu. Kamu juga dapat mengatur perdagangan secara manual dengan memilih “Hentikan Perdagangan” atau “Mulai Perdagangan”.", + "build_warship": "Kapal Perang", + "build_warship_desc": "Berpatroli di suatu area, menangkap kapal dagang musuh serta menghancurkan Kapal (kapal pengangkut) dan Kapal Perang mereka. Unit ini muncul dari Pelabuhan terdekat dan akan berpatroli di area yang pertama kali kamu klik saat membangunnya.\nKamu dapat mengendalikan Kapal Perang dengan klik-serang pada unit tersebut (lihat aksi Serang pada menu Hotkeys), lalu klik-serang area baru yang ingin dituju.", + "build_silo": "Silo Peluncur Rudal", + "build_silo_desc": "Memungkinkan peluncuran rudal.", + "build_sam": "Peluncur Rudal SAM", + "build_sam_desc": "Dapat mencegat rudal musuh dalam jangkauan 100 piksel. Peluncur SAM memiliki waktu jeda cooldown 7,5 detik.", + "build_atom": "Bom Atom", + "build_atom_desc": "Bom kecil berdaya ledak tinggi yang menghancurkan wilayah, bangunan, kapal, dan perahu. Muncul dari Silo Rudal terdekat dan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "build_hydrogen": "Bom Hidrogen", + "build_hydrogen_desc": "Bom berdaya ledak besar. Muncul dari Silo Rudal terdekat dan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "build_mirv": "MIRV", + "build_mirv_desc": "Bom paling kuat di dalam permainan. Akan terpecah menjadi bom-bom yang lebih kecil dan mencakup area wilayah yang sangat luas. Hanya memberikan kerusakan kepada pemain yang pertama kali kamu klik saat membangunnya.\nSenjata ini muncul dari Silo Rudal terdekat dan akan mendarat di area yang pertama kali kamu klik saat membangunnya.", + "player_icons": "Ikon Pemain", + "icon_desc": "Berikut beberapa ikon yang akan kamu temui di dalam permainan beserta artinya:", + "icon_crown": "Mahkota – Peringkat 1. Pemain teratas di papan peringkat.", + "icon_traitor": "Perisai Retak – Pengkhianat. Pemain ini menyerang sekutu.", + "icon_ally": "Jabat Tangan – Sekutu. Pemain ini adalah sekutumu.", + "icon_embargo": "Tanda Dolar Dicoret – Embargo. Pemain ini menghentikan perdagangan denganmu, baik secara otomatis maupun manual.", + "icon_request": "Amplop – Permintaan Aliansi. Pemain ini mengirim permintaan aliansi kepadamu.", + "info_enemy_panel": "Panel Info Musuh", + "exit_confirmation": "Apakah yakin keluar dari game?", + "bomb_direction": "Arah busur bom atom/hidrogen" + }, + "single_modal": { + "title": "Sendiri", + "random_spawn": "Kemunculan acak", + "allow_alliances": "Perbolehkan Aliansi", + "toggle_achievements": "Tampilkan / Sembunyikan pencapaian", + "sign_in_for_achievements": "Masuk untuk melihat pencapaian", + "options_title": "Opsi", + "bots": "Bot: ", + "bots_disabled": "Dinonaktifkan", + "nations": "Bangsa-bangsa: ", + "disable_nations": "Nonaktifkan negara", + "instant_build": "Bangun instan", + "infinite_gold": "Emas tak terbatas", + "infinite_troops": "Pasukan tak terbatas", + "compact_map": "Peta Kecil", + "max_timer": "Lama permainan (menit)", + "max_timer_placeholder": "Menit", + "max_timer_invalid": "Silakan masukkan nilai pengatur waktu maksimum yang valid (1-120 menit)", + "disable_nukes": "Nonaktifkan Senjata Nuklir", + "enables_title": "Aktifkan Pengaturan", + "start": "Mulai Permainan" + }, + "token_login_modal": { + "title": "Sedang masuk...", + "logging_in": "Sedang masuk...", + "success": "Berhasil masuk sebagai {email}!" + }, + "account_modal": { + "title": "Akun", + "connected_as": "Terhubung sebagai", + "stats_overview": "Gambaran Umum Statistik", + "link_discord": "Tautkan Akun Discord", + "log_out": "Keluar", + "sign_in_desc": "Masuk untuk menyimpan statistik dan kemajuan Anda", + "or": "ATAU", + "email_placeholder": "Masukkan alamat email Anda", + "get_magic_link": "Dapatkan Tautan Ajaib", + "linked_account": "Masuk sebagai {account_name}", + "fetching_account": "Mengambil informasi akun...", + "recovery_email_sent": "Pemulihan email dikirim ke {email}", + "not_found": "Tidak Ditemukan", + "clear_session": "Hapus Sesi", + "failed_to_send_recovery_email": "Gagal mengirim pemulihan email", + "enter_email_address": "Silahkan masukan alamat email" + }, + "stats_modal": { + "title": "Statistik", + "clan_stats": "Statistik Klan", + "loading": "Loading...", + "error": "Error saat memuat statistik klan", + "no_stats": "Tidak ada klan yang tersedia", + "no_data_yet": "Data belum tersedia", + "clan": "Klan", + "games": "Permainan", + "win_score": "Skor Kemenangan", + "win_score_tooltip": "Kemenangan dihitung berdasarkan bobot partisipasi klan dan tingkat kesulitan pertandingan", + "loss_score": "Skor Kekalahan", + "loss_score_tooltip": "Kerugian dihitung berdasarkan partisipasi klan dan kesulitan pertandingan", + "win_loss_ratio": "Menang/Kalah", + "ratio": "Rasio", + "rank": "Peringkat", + "try_again": "Coba Lagi" + }, + "game_info_modal": { + "title": "Informasi Permainan", + "players": "Pemain", + "atoms": "Atom", + "hydros": "Hidro", + "mirv": "MIRV", + "bombs": "Bom", + "total_gold": "Total", + "all_gold": "Semua emas", + "trade": "Perdagangan", + "conquest_gold": "Emas pemain yang ditaklukan", + "stolen_gold": "Dicuri oleh Kapal Perang", + "num_of_conquests": "Jumlah pemain yang ditaklukan", + "duration": "Durasi", + "survival_time": "Menit Bertahan", + "war": "Perang", + "economy": "Ekonomi", + "conquests": "Penaklukan", + "pirate": "Bajak Laut", + "conquered": "Ditaklukan", + "loading_game_info": "Memuat Statistik Permainan", + "no_winner": "Permainan ini berakhir tanpa pemenang (atau Negara menang)" + }, + "map": { + "map": "Peta", + "world": "Dunia", + "giantworldmap": "Map Dunia Besar", + "europe": "Eropa", + "mena": "MENA", + "northamerica": "Amerika Utara", + "oceania": "Oseania", + "blacksea": "Laut Hitam", + "africa": "Afrika", + "asia": "Asia", + "mars": "Mars", + "southamerica": "Amerika Selatan", + "britanniaclassic": "Britania (klasik)", + "britannia": "Britania", + "gatewaytotheatlantic": "Pintu masuk menuju Atlantik", + "australia": "Australia", + "random": "Random", + "iceland": "Islandia", + "pangaea": "Pangea", + "eastasia": "Asia Timur", + "betweentwoseas": "Diantara Dua Laut", + "faroeislands": "Kepulauan Faroe", + "deglaciatedantarctica": "Antartika yang telah bebas dari gletser", + "europeclassic": "Eropa (klasik)", + "falklandislands": "Kepulauan Falkland", + "baikal": "Baikal", + "halkidiki": "Kalkidiki", + "straitofgibraltar": "Selat Gibraltar", + "italia": "Italia", + "japan": "Jepang", + "yenisei": "Sungai Yenisei", + "pluto": "Pluto", + "montreal": "Montreal", + "newyorkcity": "Kota New York", + "achiran": "Sungai Akheron", + "baikalnukewars": "Baikal (Perang Nuklir)", + "fourislands": "Empat Pulau", + "gulfofstlawrence": "Teluk St. Lawrence", + "lisbon": "Lisboa", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "Dua Danau", + "straitofhormuz": "Selat Hormuz", + "surrounded": "Surrourded", + "didier": "Didier", + "didierfrance": "Didier (Prancis)", + "amazonriver": "Sungai Amazon" + }, + "map_categories": { + "continental": "Kontinental", + "regional": "Regional", + "fantasy": "Lain", + "special": "Spesial", + "arcade": "Arkade" + }, + "map_component": { + "loading": "Loading...", + "error": "Kesalahan" + }, + "private_lobby": { + "title": "Gabung Lobi Privat", + "enter_id": "Masukan ID Lobi", + "player": "Pemain", + "players": "Pemain", + "join_lobby": "Bergabung ke Lobi", + "checking": "Memeriksa Lobi...", + "not_found": "Lobi tidak ditemukan. Mohon periksa ID dan coba lagi.", + "error": "Beberapa kesalahan terjadi. Silakan coba lagi atau hubungi dukungan.", + "joined_waiting": "Berhasil gabung ke lobi! Menunggu untuk penyelenggara untuk memulai...", + "version_mismatch": "Permainan ini dibuat dengan versi yang berbeda. Tidak dapat gabung.", + "disabled_units": "Nonaktfikan Units" + }, + "public_lobby": { + "join": "Gabung ke permainan selanjutnya", + "waiting": "Pemain menunggu", + "teams_Duos": "{team_count} tim berisi 2 pemain (Berdua)", + "teams_Trios": "{team_count} tim berisi 3 pemain (Bertiga)", + "teams_Quads": "{team_count} tim berisi 4 pemain (Berempat)", + "waiting_for_players": "Menunggu pemain", + "starting_game": "Memulai permainan…", + "teams_hvn": "Pemain vs Negara", + "teams_hvn_detailed": "{num} Pemain vs {num} Negara", + "teams": "{num} tim", + "players_per_team": "dari {num}", + "started": "Dimulai" + }, + "matchmaking_modal": { + "title": "Pertandingan 1v1 Ranked (ALPHA)", + "connecting": "Menghubungkan ke server pencarian lawan...", + "searching": "Mencari permainan...", + "waiting_for_game": "Menunggu permainan untuk dimulai...", + "elo": "ELO anda: {elo}" + }, + "username": { + "enter_username": "Masukkan nama pengguna", + "not_string": "Nama pengguna harus berupa string.", + "too_short": "Nama pengguna harus memiliki panjang minimal {min} karakter.", + "too_long": "Nama pengguna tidak boleh melebihi {max} karakter.", + "invalid_chars": "Nama pengguna hanya boleh berupa huruf, angka, spasi dan garis bawah.", + "tag": "Tag", + "tag_too_short": "Nama klan harus terdiri dari 2-5 karakter alfanumerik.", + "tag_invalid_chars": "Tag klan hanya boleh berisi huruf dan angka" + }, + "host_modal": { + "title": "Buat Lobi Tertutup", + "label": "Tertutup", + "mode": "Mode", + "team_count": "Jumlah Tim", + "team_type": "Tipe Tim", + "options_title": "Pilihan", + "bots": "Bot: ", + "bots_disabled": "Nonaktif", + "player_immunity_duration": "Durasi imunitas PVP (menit)", + "nations": "Bangsa-bangsa: ", + "disable_nations": "Nonaktifkan Negara", + "max_timer": "Lama permainan (menit)", + "mins_placeholder": "Menit", + "instant_build": "Bangun instan", + "infinite_gold": "Emas tak terbatas", + "donate_gold": "Donasikan emas", + "infinite_troops": "Pasukan tak terbatas", + "donate_troops": "Donasikan pasukan", + "compact_map": "Peta Kecil", + "enables_title": "Aktifkan Pengaturan", + "player": "Pemain", + "players": "Pemain", + "nation_players": "Bangsa-bangsa", + "nation_player": "Bangsa", + "waiting": "Menunggu pemain...", + "random_spawn": "Kemunculan Acak", + "start": "Mulai Permainan", + "host_badge": "Host", + "assigned_teams": "Tim yang Ditugaskan", + "empty_teams": "Tim Kosong", + "empty_team": "Kosong", + "remove_player": "Hapus {username}", + "teams_Duos": "Berdua (tim yang terdiri dari 2 orang)", + "teams_Trios": "Bertiga (tim yang terdiri dari 3 orang)", + "teams_Quads": "Berempat (tim yang teridri dari 4 orang)", + "teams_Humans Vs Nations": "Pemain vs Negara" + }, + "team_colors": { + "red": "Merah", + "blue": "Biru", + "teal": "Hijau Laut", + "purple": "Ungu", + "yellow": "Kuning", + "orange": "Oranye", + "green": "Hijau", + "bot": "Bot" + }, + "game_starting_modal": { + "title": "Memulai Permainan...", + "credits": "Kredit", + "code_license": "Kode berlisensi AGPL-3.0 (tanpa garansi)" + }, + "difficulty": { + "difficulty": "Kesulitan Negara", + "easy": "Mudah", + "medium": "Sedang", + "hard": "Sulit", + "impossible": "Mustahil" + }, + "game_mode": { + "ffa": "Siapapun bisa bergabung", + "teams": "Tim-tim" + }, + "public_game_modifier": { + "random_spawn": "Kemunculan Acak", + "compact_map": "Peta Kecil" + }, + "select_lang": { + "title": "Pilih Bahasa" + }, + "unit_type": { + "city": "Kota", + "defense_post": "Pos Pertahanan", + "port": "Pelabuhan", + "warship": "Kapal Perang", + "missile_silo": "Silo Peluncur Rudal", + "sam_launcher": "Peluncur Rudal SAM", + "atom_bomb": "Bom Atom", + "hydrogen_bomb": "Bom Hidrogen", + "mirv": "MIRV", + "factory": "Pabrik" + }, + "user_setting": { + "title": "Pengaturan", + "tab_basic": "Pengaturan Dasasr", + "tab_keybinds": "Tombol pintasan", + "dark_mode_label": "Mode Gelap", + "dark_mode_desc": "Beralih tampilan situs antara tema terang dan gelap", + "emojis_label": "Emoji", + "emojis_desc": "Alihkan tampilan emoji di dalam game", + "alert_frame_label": "Bingkai Peringatan", + "alert_frame_desc": "Aktifkan / Nonaktifkan bingkai peringatan. Saat diaktifkan, bingkai akan ditampilkan ketika kamu dikhianati atau diserang melalui darat.", + "special_effects_label": "Efek Spesial", + "special_effects_desc": "Alihkan efek khusus. Nonaktifkan untuk meningkatkan performa", + "structure_sprites_label": "Sprite Bangunan", + "structure_sprites_desc": "Alihkan tampilan sprite bangunan", + "cursor_cost_label_label": "Biaya Pembangunan Kursor", + "cursor_cost_label_desc": "Tampilkan label biaya di bawah ikon kursor pembangunan", + "anonymous_names_label": "Sembunyikan Nama", + "anonymous_names_desc": "Sembunyikan nama asli pemain dengan nama acak di layar Anda.", + "lobby_id_visibility_label": "Sembunyikan ID Lobby", + "lobby_id_visibility_desc": "Sembunyikan ID Lobby saat membuat lobby pribadi", + "toggle_visibility": "Alihkan Visibilitas", + "left_click_label": "Klik Kiri untuk Membuka Menu", + "left_click_desc": "Saat AKTIF, klik kiri membuka menu dan tombol pedang digunakan untuk menyerang. Saat NONAKTIF, klik kiri langsung melakukan serangan.", + "left_click_menu": "Klik Kiri untuk Menu", + "attack_ratio_label": "⚔️ Rasio Serangan", + "attack_ratio_desc": "Persentase pasukan yang dikirim saat menyerang (1–100%)", + "territory_patterns_label": "🏳️ Skin Wilayah", + "territory_patterns_desc": "Pilih apakah ingin menampilkan desain skin wilayah di dalam game", + "performance_overlay_label": "Tampilan Performa", + "performance_overlay_desc": "Aktifkan / Nonaktifkan overlay performa.\nSaat diaktifkan, overlay performa akan ditampilkan. Tekan Shift + D saat permainan berlangsung untuk mengaktifkan atau menonaktifkannya.", + "easter_writing_speed_label": "Multiplier Kecepatan Menulis", + "easter_writing_speed_desc": "Atur seberapa cepat kamu berpura-pura coding (x1–x100)", + "easter_bug_count_label": "Jumlah Bug", + "easter_bug_count_desc": "Seberapa banyak bug yang masih bisa Anda toleransi (0–1000, secara emosional)", + "press_a_key": "Tekan tombol", + "view_options": "Opsi Tampilan", + "toggle_view": "Alihkan Tampilan", + "toggle_view_desc": "Ganti Tampilan (Medan / Negara)", + "build_controls": "Kontrol Pembangunan", + "build_city": "Membangun Kota", + "build_city_desc": "Bangun Kota di bawah kursor Anda.", + "build_factory": "Bangun Pabrik", + "build_factory_desc": "Bangun Pabrik di bawah kursor Anda.", + "build_defense_post": "Bangun Pos Pertahanan", + "build_defense_post_desc": "Bangun Pos Pertahanan di bawah kursor Anda.", + "build_port": "Membangun Pelabuhan", + "build_port_desc": "Bangun Pelabuhan di bawah kursor Anda.", + "build_warship": "Bangun Kapal Perang", + "build_warship_desc": "Bangun Kapal Perang di bawah kursor Anda.", + "build_missile_silo": "Bangun Silo Peluncur Rudal", + "build_missile_silo_desc": "Bangun Silo Peluncur Rudal di bawah kursor Anda.", + "build_sam_launcher": "Bangun Peluncur Rudal SAM", + "build_sam_launcher_desc": "Bangun Peluncur Rudal SAM di bawah kursor Anda.", + "build_atom_bomb": "Bangun Bom Atom", + "build_atom_bomb_desc": "Bangun Bom Atom di bawah kursor Anda.", + "build_hydrogen_bomb": "Bangun Bom Hidrogen", + "build_hydrogen_bomb_desc": "Membangun Bom Hidrogen di bawah kursor Anda.", + "build_mirv": "Membangun MIRV", + "build_mirv_desc": "Bangun MIRV di bawah kursor Anda.", + "menu_shortcuts": "Menu Pintasan", + "build_menu_modifier": "Pengubah Menu Pembangunan", + "build_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu pembuatan.", + "emoji_menu_modifier": "Pengubah Menu Emoji", + "emoji_menu_modifier_desc": "Tahan tombol ini sambil mengklik untuk membuka menu emoji.", + "attack_ratio_controls": "Kontrol Rasio Serangan", + "attack_ratio_up": "Tingkatkan Rasio Serangan", + "attack_ratio_up_desc": "Tingkatkan Rasio Serangan sebesar 10%", + "attack_ratio_down": "Kurangi Rasio Serangan", + "attack_ratio_down_desc": "Kurangi Rasio Serangan sebesar 10%", + "attack_keybinds": "Tombol pintas untuk Serangan", + "boat_attack": "Serangan Kapal Pengangkut", + "boat_attack_desc": "Kirim serangan kapal ke petak di bawah kursor Anda.", + "ground_attack": "Serangan Darat", + "ground_attack_desc": "Kirim serangan darat ke petak di bawah kursor Anda.", + "swap_direction": "Tukar Arah Roket", + "swap_direction_desc": "Ubah arah peluncuran roket (atas/bawah).", + "zoom_controls": "Kontrol Zoom", + "zoom_out": "Perkecil tampilan", + "zoom_out_desc": "Perkecil tampilan peta", + "zoom_in": "Perbesar", + "zoom_in_desc": "Perbesar tampilan peta", + "camera_movement": "Pergerakan Kamera", + "center_camera": "Sorot kamera ke tengah", + "center_camera_desc": "Pusatkan kamera pada pemain", + "move_up": "Pindahkan kamera ke atas", + "move_up_desc": "Memindahkan kamera ke atas", + "move_left": "Pindahkan kamera ke kiri", + "move_left_desc": "Memindahkan kamera ke kiri", + "move_down": "Pindahkan kamera ke bawah", + "move_down_desc": "Memindahkan kamera ke bawah", + "move_right": "Pindahkan kemara ke kanan", + "move_right_desc": "Memindahkan kamera ke kanan", + "reset": "Reset", + "unbind": "Batalkan pengikatan tombol", + "on": "Hidup", + "off": "Mati", + "toggle_terrain": "Tampilkan / Sembunyikan Medan", + "exit_game_label": "Keluar Game", + "exit_game_info": "Kembali ke menu utama", + "background_music_volume": "Volume latar belakang musik", + "sound_effects_volume": "Volume Efek Suara", + "keybind_conflict_error": "Tombol {key} sudah terikat ke aksi lain." + }, + "chat": { + "title": "Obrolan Cepat", + "to": "Dari {user}: {msg}", + "from": "Dari {user}: {msg}", + "category": "Kategori", + "phrase": "Frase", + "player": "Pemain", + "send": "Kirim", + "search": "Cari pemain...", + "build": "Ketik pesanmu...", + "cat": { + "help": "Bantuan", + "attack": "Serang", + "defend": "Bertahan", + "greet": "Salam", + "misc": "Lain-lain", + "warnings": "Peringatan" + }, + "help": { + "troops": "Tolong berikan saya tentara!", + "troops_frontlines": "Kirim pasukan ke garis depan!", + "gold": "Tolong berikan saya emas!", + "no_attack": "Tolong jangan serang saya!", + "sorry_attack": "Maaf, Saya tidak bermaksud untuk menyerang Anda.", + "alliance": "Aliansi?", + "help_defend": "Bantu saya bertahan dari [P1]!", + "trade_partners": "Mari menjadi mitra dagang!" + }, + "attack": { + "attack": "Serang [P1]!", + "mirv": "Luncurkan MIRV ke [P1]!", + "focus": "Fokus serangan pada [P1]!", + "finish": "Mari selesaikan [P1]!", + "build_warships": "Bangun Kapal-Kapal Perang!" + }, + "defend": { + "defend": "Pertahankan [P1]!", + "defend_from": "Bertahan dari [P1]!", + "dont_attack": "Jangan serang [P1]!", + "ally": "[P1] adalah aliansi saya!", + "build_posts": "Bangun Pos Pertahanan!" + }, + "greet": { + "hello": "Halo!", + "good_job": "Kerja bagus!", + "good_luck": "Semoga sukses!", + "have_fun": "Selamat bersenang-senang!", + "gg": "GG!", + "nice_to_meet": "Senang bertemu denganmu!", + "well_played": "Bagus Sekali!", + "hi_again": "Halo lagi!", + "bye": "Da!", + "thanks": "Terima kasih!", + "oops": "Ups, salah tombol!", + "trust_me": "Anda bisa percaya saya. Janji!", + "trust_broken": "Aku percaya padamu...", + "ruining_games": "Kamu bikin permainan kita berdua jadi kacau.", + "dont_do_that": "Jangan!", + "same_team": "Saya di pihak Anda!" + }, + "misc": { + "go": "Ayo!", + "strategy": "Strategi yang mantap!", + "fun": "Permainan ini seru!", + "team_up": "Mari menyerang [P1] bersama-sama!", + "pr": "Kapan PR-ku akhirnya akan digabungkan...?", + "build_closer": "Bangun lebih dekat agar membuat jalur kereta!", + "coastline": "Tolong izinkan saya mendapatkan garis pantai." + }, + "warnings": { + "strong": "[P1] kuat.", + "weak": "[P1] lemah.", + "mirv_soon": "[P1] akan meluncurkan MIRV segera!", + "number1_warning": "Pemain nomor 1 akan segera menang kecuali kita bekerja sama!", + "stalemate": "Mari berdamai. Ini jalan buntu, kita berdua akan kalah.", + "has_allies": "[P1] punya banyak sekutu.", + "no_allies": "[P1] tidak punya sekutu.", + "betrayed": "[P1] menkhianati sekutu dia!", + "betrayed_me": "[P1] menkhianati saya!", + "getting_big": "[P1] berkembang sangat cepat!", + "danger_base": "[P1] tidak terproteksi!", + "saving_for_mirv": "[P1] sedang menabung untuk meluncurkan MIRV.", + "mirv_ready": "[P1] punya cukup emas untuk meluncurkan MIRV!", + "snowballing": "[P1] berkembang terlalu cepat!", + "cheating": "[P1] curang!", + "stop_trading": "Stop berdangan dengan [P1]!" + } + }, + "build_menu": { + "desc": { + "atom_bomb": "Ledakan kecil", + "hydrogen_bomb": "Ledakan dahsyat", + "mirv": "Ledakan Dahsyat, hanya menargetkan pemain yang dipilih", + "missile_silo": "Digunakan untuk meluncurkan nuklir", + "sam_launcher": "Penangkalan nuklir yang mendekat", + "warship": "Menangkap kapal dagang, menghancurkan kapal dan perahu.", + "port": "Mengirim kapal untuk mendapatkan emas", + "defense_post": "Meningkatkan pertahanan perbatasan", + "city": "Meningkatkan jumlah maksimal populasi", + "factory": "Membuat rel dan memunculkan kereta" + }, + "not_enough_money": "Uang tidak cukup" + }, + "win_modal": { + "support_openfront": "Dukung OpenFront!", + "territory_pattern": "Beli skin wilayah untuk bebas iklan!", + "died": "Anda meninggal", + "your_team": "Tim Anda menang!", + "other_team": "tim {team} menang!", + "you_won": "Anda Menang!", + "other_won": "{player} menang!", + "nation_won": "Negara {nation} menang!", + "exit": "Keluar Game", + "keep": "Terus Main", + "spectate": "Menonton", + "wishlist": "Wishlist di Steam!", + "ofm_winter": "Turnamen Musim Dingin OpenFront Masters!", + "ofm_winter_description": "Ikuti turnamen kompetitif dan bersaing melawan pemain terbaik", + "join_tournament": "Ikut Turnamen", + "join_discord": "Gabung Komunitas Discord Kami!", + "discord_description": "Terhubung dengan pemain lain, temukan fitur baru, dan menangkan hadiah!", + "join_server": "Bergabung dengan Server", + "youtube_tutorial": "Butuh bantuan?" + }, + "leaderboard": { + "title": "Papan Peringkat", + "hide": "Sembunyikan", + "rank": "Peringkat", + "player": "Pemain", + "team": "Tim", + "owned": "Dimiliki", + "gold": "Emas", + "maxtroops": "Maksimal pasukan", + "launchers": "Peluncur", + "sams": "SAM-SAM", + "warships": "Kapal Perang", + "cities": "Kota-Kota", + "show_control": "Tampilkan Kontrol", + "show_units": "Tampilkan Unit" + }, + "player_info_overlay": { + "type": "Jenis", + "bot": "Bot", + "nation": "Bangsa", + "player": "Pemain", + "team": "Tim", + "alliance_timeout": "Aliansi berakhir dalam", + "troops": "Pasukan", + "maxtroops": "Maksimal pasukan", + "a_troops": "Pasukan menyerang", + "gold": "Emas", + "ports": "Pelabuhan-Pelabuhan", + "cities": "Kota-kota", + "factories": "Pabrik-pabrik", + "missile_launchers": "Peluncur rudal", + "sams": "SAM", + "warships": "Kapal Perang", + "health": "Kesehatan", + "attitude": "Sikap", + "levels": "Tingkat", + "wilderness_title": "Alam Liar", + "irradiated_wilderness_title": "Hutan Belantara yang Terkena Radiasi" + }, + "events_display": { + "retreating": "mundur", + "retaliate": "Membalas", + "boat": "Perahu", + "alliance_request_status": "{name} {status} permintaan aliansi Anda", + "alliance_accepted": "diterima", + "alliance_rejected": "ditolak", + "duration_second": "1 detik", + "betrayal_description": "Kamu memutus aliansi dengan {name}, menjadikanmu PENGKHIANAT ({malusPercent}% pengurangan pertahanan selama {durationText})", + "duration_seconds_plural": "{seconds} detik", + "betrayed_you": "{name} memutus aliansi dengan Anda", + "about_to_expire": "Aliansi Anda dengan {name} hampir berakhir!", + "alliance_expired": "Aliansi Anda dengan {name} berakhir", + "attack_request": "{name} meminta Anda untuk menyerang {target}", + "sent_emoji": "Dari {name}: {emoji}", + "renew_alliance": "Minta untuk memperpanjang", + "request_alliance": "{name} meminta aliansi!", + "focus": "Fokus", + "accept_alliance": "Setuju", + "reject_alliance": "Tolak", + "alliance_renewed": "Aliansi anda dengan {name} sudah di perpanjang", + "wants_to_renew_alliance": "{name} ingin memperpanjang aliansi", + "ignore": "Abaikan", + "unit_voluntarily_deleted": "Unit dihapus secara sukarela", + "betrayal_debuff_ends": "{time} detik tersisa hingga efek negatif pengkhianatan berakhir", + "attack_cancelled_retreat": "Penyerangan dibatalkan, {troops} pasukan terbunuh saat mundur", + "received_gold_from_captured_ship": "Menerima {gold} emas dari kapal yang di tawan dari {name}", + "received_gold_from_trade": "Menerima {gold} emas dari perdagangan dengan {name}", + "missile_intercepted": "Rudal dicegat {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} hulu ledak MIRV berhasil dicegat} other {{count} hulu ledak MIRV berhasil dicegat}}", + "sent_troops_to_player": "Mengirim {troops} pasukan ke {name}", + "received_troops_from_player": "Menerima {troops} pasukan dari {name}", + "sent_gold_to_player": "Mengirim {gold} emas ke {name}", + "received_gold_from_player": "Menerima {gold} emas dari {name}", + "unit_captured_by_enemy": "{unit} Anda ditangkap oleh {name}", + "captured_enemy_unit": "Menangkap {unit} dari {name}", + "unit_destroyed": "{unit} Anda dihancurkan", + "no_boats_available": "Tidak ada kapal yang tersedia, maksmial {max}" + }, + "unit_info_modal": { + "structure_info": "Informasi Struktur", + "unit_type_unknown": "Tidak Diketahui", + "close": "Keluar", + "cooldown": "Cooldown", + "type": "Jenis", + "upgrade": "Tingkatkan", + "level": "Tingkat" + }, + "player_type": { + "player": "Pemain", + "nation": "Bangsa", + "bot": "Bot" + }, + "relation": { + "hostile": "Berseteru", + "distrustful": "Tak dapat dipercaya", + "neutral": "Netral", + "friendly": "Ramah", + "default": "Default" + }, + "control_panel": { + "gold": "Emas", + "troops": "Pasukan", + "attack_ratio": "Rasio Serangan" + }, + "player_panel": { + "gold": "Emas", + "troops": "Pasukan", + "betrayals": "Pengkhianatan", + "traitor": "Pengkhianat", + "trading": "Perdagangan", + "active": "Aktif", + "stopped": "Berhenti", + "alliance_time_remaining": "Aliansi Berakhir Dalam", + "embargo": "Berhenti berdangan dengan Anda", + "nuke": "Nuklir dikirim oleh mereka kepada Anda", + "start_trade": "Mulai Berdagang", + "stop_trade": "Stop Berdagang", + "stop_trade_all": "Stop Berdagang degnan Semuanya", + "start_trade_all": "Mulai Berdagang dengan Semuanya", + "alliances": "Aliansi", + "flag": "Bendera", + "chat": "Chat", + "target": "Sasaran", + "break_alliance": "Rusak Aliansi", + "alliance": "Aliansi", + "send_alliance": "Kirim Proposal Aliansi", + "send_troops": "Kirim Pasukan", + "send_gold": "Kirim Emas", + "emotes": "Emoji", + "arc_up": "Lengkungan ke Atas", + "arc_down": "Lengkungan ke Bawah", + "flip_rocket_trajectory": "Balikkan lintasan roket" + }, + "send_troops_modal": { + "title_with_name": "Kirim Pasukan ke {name}", + "available_tooltip": "Pasukan Anda yang tersedia saat ini", + "min_keep": "Minimal yang ditinggalkan", + "slider_tooltip": "{{percent}}% • {{amount}}", + "aria_slider": "Penggeser pasukan", + "capacity_note": "Penerima hanya dapat menerima {{amount}} saat ini." + }, + "send_gold_modal": { + "title_with_name": "Kirim Emas ke {name}", + "available_tooltip": "Emas yang Anda miliki saat ini", + "aria_slider": "Penggeser jumlah", + "slider_tooltip": "{{percent}}% • {{amount}}" + }, + "replay_panel": { + "replay_speed": "Kecepatan tanyangan ulang", + "game_speed": "Kecepatan Permainan", + "fastest_game_speed": "Maks" + }, + "error_modal": { + "crashed": "Game berhenti / rusak!", + "connection_error": "Kesalahan koneksi!", + "paste_discord": "Silakan tempelkan teks berikut di laporan bug Anda di Discord:", + "copy_clipboard": "Salin ke papan klip", + "copied": "Tersalin!", + "failed_copy": "Gagal menyalin", + "spawn_failed": { + "title": "Kemunculan gagal", + "description": "Pemilihan titik awal otomatis gagal. Anda tidak dapat memainkan game ini." + }, + "desync_notice": "Anda tidak tersinkronisasi dengan pemain lain. Apa yang Anda lihat mungkin berbeda dari pemain lain." + }, + "performance_overlay": { + "reset": "Set ulang", + "copy_json_title": "Salin metrik kinerja saat ini sebagai JSON.", + "copy_clipboard": "Menyalin JSON", + "copied": "Tersalin!", + "failed_copy": "Gagal menyalin", + "fps": "FPS:", + "avg_60s": "Rata-rata (60d):", + "frame": "Bingkai:", + "tick_exec": "Eksekutif Tick:", + "tick_delay": "Penundaan Detik:", + "layers_header": "Lapisan (rata-rata / maksimum, diurutkan berdasarkan total waktu):" + }, + "heads_up_message": { + "choose_spawn": "Pilih lokasi awal", + "random_spawn": "Kemunculan acak diaktifkan. Memilih lokasi awal untuk Anda...", + "singleplayer_game_paused": "Permainan dijeda", + "multiplayer_game_paused": "Permainan di tunda oleh Pembuat Lobi" + }, + "territory_patterns": { + "title": "Tampilan", + "colors": "Warna-Warna", + "purchase": "Beli", + "show_only_owned": "Skin Saya", + "all_owned": "Semua skin sudah dimiliki! Silakan periksa kembali nanti untuk item baru.", + "not_logged_in": "Belum masuk", + "blocked": { + "login": "Anda harus login untuk mengakses skin ini.", + "purchase": "Beli skin ini untuk membukanya." + }, + "pattern": { + "default": "Default" + }, + "select_skin": "Pilih Skin", + "selected": "dipilih" + }, + "flag_input": { + "title": "Pilih Bendera", + "button_title": "Pilih bendera!", + "search_flag": "Cari..." + }, + "spawn_ad": { + "loading": "Memuat iklan..." + }, + "auth": { + "login_required": "Masuk dibutuhkan untuk mengakses website ini.", + "redirecting": "Anda sedang diarahkan...", + "not_authorized": "Anda tidak punya izin untuk mengakses website ini.", + "contact_admin": "Jika Anda yakin melihat pesan ini karena kesalahan, silakan hubungi administrator situs web." + }, + "radial_menu": { + "delete_unit_title": "Hapus Unit", + "delete_unit_description": "Klik untuk menghapus unit terdekat" + }, + "discord_user_header": { + "avatar_alt": "Avatar" + }, + "player_stats_table": { + "building_stats": "Statistik Bangunan", + "ship_arrivals": "Kedatangan Kapal", + "nuke_stats": "Statistik Nuklir", + "player_metrics": "Metrik Pemain", + "building": "Gedung", + "ship_type": "Jenis Kapal", + "weapon": "Senjata", + "built": "Bangun", + "destroyed": "Telah Hancur", + "captured": "Ditangkap", + "lost": "Kalah", + "hits": "Hits", + "launched": "Telah Diluncurkan", + "landed": "Mendarat", + "sent": "Terkirim", + "arrived": "Tiba", + "attack": "Serang", + "received": "Diterima", + "cancelled": "Dibatalkan", + "count": "Hitungan", + "gold": "Emas", + "workers": "Pekerja", + "war": "Perang", + "trade": "Perdagangan", + "steal": "Steal", + "unit": { + "city": "Kota", + "port": "Pelabuhan", + "defp": "Pos Pertahanan", + "saml": "Peluncur Rudal SAM", + "silo": "Silo Peluncur Rudal", + "wshp": "Kapal Perang", + "fact": "Pabrik", + "trade": "Kapal Perdagangan", + "trans": "Kapal Pengangkut", + "abomb": "Bom Atom", + "hbomb": "Bom Hidrogen", + "mirv": "MIRV", + "mirvw": "Hulu ledak MIRV" + } + }, + "game_list": { + "recent_games": "Permainan Terbaru", + "game_id": "ID Permainan", + "mode": "Mode", + "mode_ffa": "Siapapun bisa bergabung", + "mode_team": "Tim", + "replay": "Tayangan ulang", + "details": "Detail", + "ranking": "Peringkat", + "started": "Dimulai", + "map": "Peta", + "difficulty": "Tingkat Kesulitan", + "type": "Jenis" + }, + "player_stats_tree": { + "public": "Publik", + "private": "Tertutup", + "singleplayer": "Sendiri", + "mode": "Mode", + "stats_wins": "Jumlah Kemenangan", + "stats_losses": "Jumlah Kehilangan", + "stats_wlr": "Menang:Kalah Rasio", + "stats_games_played": "Permainan Dimainkan", + "mode_ffa": "Siapapun bisa bergabung", + "mode_team": "Tim", + "no_stats": "Tidak ada statistik yang tercatat untuk pilihan ini." + }, + "matchmaking_button": { + "play_ranked": "Pertandingan 1v1 Ranked", + "description": "(ALPHA)", + "login_required": "Masuk untuk bermain peringkat!", + "must_login": "Anda harus masuk untuk bermain di pertandingan 1v1 Ranked." + } +} diff --git a/resources/lang/ja.json b/resources/lang/ja.json index 33bcbc2bf..89751baae 100644 --- a/resources/lang/ja.json +++ b/resources/lang/ja.json @@ -7,6 +7,7 @@ }, "common": { "close": "閉じる", + "back": "戻る", "available": "利用可能", "preset_max": "最大", "summary_send": "送る", @@ -17,26 +18,42 @@ "cap_tooltip": "受取主が受け取れる量", "target_dead": "ターゲットは排除されました", "target_dead_note": "排除されたプレイヤーにはリソースを送ることができません。", - "none": "なし" + "none": "なし", + "copied": "コピーに成功しました!", + "click_to_copy": "クリックしてコピー" }, "main": { "title": "OpenFront (ALPHA)", "join_discord": "Discord", "login_discord": "Discordでログイン", + "sign_in": "サインイン", + "discord_avatar_alt": "Discordのプロフィールアバター", + "user_avatar_alt": "{username}のアバター", "checking_login": "ログイン中...", "logged_in": "ログイン中!", "log_out": "ログアウト", - "create_lobby": "ロビーを作成", - "join_lobby": "ロビーに参加", - "single_player": "シングルプレイヤー", + "create": "ロビーを作成", + "join": "ロビーに参加", + "solo": "ソロ", "instructions": "説明書", + "game_info": "ゲームの情報", "wiki": "ウィキ", "privacy_policy": "プライバシーポリシー", "terms_of_service": "利用規約", - "reddit": "Reddit" + "copyright": "©️ OpenFront™ と貢献者", + "reddit": "Reddit", + "play": "プレイ", + "news": "お知らせ", + "store": "ストア", + "settings": "設定", + "keys": "キー設定", + "stats": "統計", + "account": "アカウント", + "help": "ヘルプ", + "menu": "メニュー", + "pick_pattern": "模様を選択してください!" }, "news": { - "see_all_releases": "すべてのリリースを見る", "github_link": "GitHub上で", "title": "更新情報" }, @@ -67,7 +84,7 @@ "ui_events_desc": "イベントパネルには、最新のイベント、リクエスト、クイックチャットメッセージが表示されます。以下がその一例です:", "ui_events_alliance": "同盟 — 同盟リクエストは承認または拒否できます。同盟関係にあるプレイヤーは資源や軍隊を共有できますが、互いに攻撃することはできません。「Focus(注視)」をクリックすると、リクエストを送ったプレイヤーの位置に画面が移動します。", "ui_events_attack": "攻撃 — 敵からの攻撃や自分の攻撃が表示されます。メッセージをクリックすると、その攻撃・核・ボート(輸送船)に画面が移動します。赤い「X」ボタンをクリックすると軍隊を撤退させることができますが、その場合攻撃部隊の25%が犠牲になります。ボート攻撃を撤退させた場合、ボートは出発地点に戻り、その地点が占領されていれば再び攻撃します。核攻撃は発射後に撤退することはできません。", - "ui_events_quickchat": "クイックチャット – ここでは送信・受信したチャットメッセージを確認できます。プレイヤーにメッセージを送るには、そのプレイヤーの情報メニューにあるクイックチャットアイコンをクリックしてください。", + "ui_events_quickchat": "クイックチャット:ここでは、送信・受信されたメッセージを確認できます。プレイヤーにメッセージを送信するには、そのプレイヤーの情報メニューにあるクイックチャットアイコンをクリックしてください。", "ui_options": "オプション", "ui_options_desc": "以下の項目が含まれます:", "ui_playeroverlay": "プレイヤー情報オーバーレイ", @@ -77,12 +94,14 @@ "option_timer": "タイマー - ゲーム開始からの経過時間", "option_exit": "終了ボタン", "option_settings": "設定メニュー - 設定メニューを開きます。左クリックでオルタネート表示、ダークモード、絵文字、アクション、匿名モードを切り替えることができます。", - "radial_title": "ラジアルメニュー", - "radial_desc": "右クリック(またはモバイルでタッチ)するとラジアルメニューが開きます。右クリックすると、ラジアルメニューを閉じます。メニューから、次のようにできます:", + "radial_title": "円形メニュー", + "radial_desc": "右クリック(またはモバイルでタッチ)すると円形メニューが開きます。右クリックすると、円形メニューを閉じます。メニューから、次のようにできます:", "radial_build": "ビルドメニューを開く。", "radial_attack": "攻撃メニューを開く。", "radial_info": "情報メニューを開く。", "radial_boat": "ボート(輸送船)を派遣して、指定した場所を攻撃します。領地が水辺に接している場合にのみ使用可能です。", + "radial_donate_troops": "円形メニューを開いた味方に、攻撃比率スライダーのパーセンテージに相当する軍隊を寄付します。", + "radial_donate_gold": "資金寄付スライダー:メニューが開き、味方に資金を素早く送信できるようになります。", "radial_close": "メニューを閉じる。", "info_title": "情報メニュー", "info_enemy_desc": "選択されたプレイヤーの名前、所持金、軍隊数、「あなたとの貿易停止」状態、あなたへの核攻撃の有無、裏切り者かどうかなどの情報を含みます。「貿易停止」とは、相手からのゴールドが受け取れず、相手も貿易船を通じてあなたにゴールドを送らなくなることを意味します。これは、手動(プレイヤーが「貿易を停止」をクリックした場合。両者が「貿易を再開」をクリックするまで継続)または、自動(同盟を裏切った場合。再度同盟になるか、5分経過するまで継続)で発生します。「裏切り者」は、そのプレイヤーが同盟中のプレイヤーを攻撃して裏切った場合に、30秒間「Yes」と表示されます。\n下のアイコンは、以下のプレイヤー間のやりとりを表しています:", @@ -110,11 +129,11 @@ "build_port": "港", "build_port_desc": "水辺にのみ建設でき、このアイコンから戦艦を建築することが可能です。自国と他国の間に貿易制限が為されていない限り、自動的に交易船を送り出し、交易が完了すると両国に資金がもたらされます。貿易は手動で「貿易停止」または「貿易開始」を切り替えることができます。また、あなたが相手を攻撃したり、攻撃された場合には交易は自動的に停止し、5分経過するか同盟を結ぶと再開されます。", "build_warship": "戦艦", - "build_warship_desc": "このユニットは、指定したエリアを巡回し、貿易船を拿捕したり、敵の軍艦やボートを撃破したりします。最寄りの港から出現し、最初にクリックした場所を巡回し始めます。軍艦は攻撃クリックで選択し、移動先を攻撃クリックすることで操作できます。", + "build_warship_desc": "このユニットは、指定したエリアを巡回し、貿易船を拿捕したり、敵の戦艦やボートを撃破したりします。最寄りの港から出現し、最初にクリックした場所を巡回し始めます。軍艦は攻撃クリックで選択し、移動先を攻撃クリックすることで操作できます。", "build_silo": "ミサイル格納庫", "build_silo_desc": "ミサイルの発射を可能にします。", "build_sam": "SAMランチャー", - "build_sam_desc": "100ピクセル以内に入った敵ミサイルを、クールダウン7.5秒で迎撃できます。命中率は、原子爆弾に対して100%、水素爆弾に対して80%、MIRVに対して50%です。", + "build_sam_desc": "半径100ピクセル内の敵ミサイルを迎撃できます。SAMのクールダウン時間は7.5秒です。", "build_atom": "原子爆弾", "build_atom_desc": "小型の爆弾で、領土・建物・船舶・ボートを破壊します。最寄りのミサイル格納庫から発射され、最初にクリックした場所に着弾します。", "build_hydrogen": "水素爆弾", @@ -129,12 +148,15 @@ "icon_embargo": "取引停止 - このプレイヤーがあなたを自動または手動で貿易制限をかけているときに表示されます。", "icon_request": "メール - このプレイヤーがあなたへ同盟の申込みをしているときに表示されます。", "info_enemy_panel": "敵の情報パネル", - "exit_confirmation": "本当にゲームを終了しますか?" + "exit_confirmation": "本当にゲームを終了しますか?", + "bomb_direction": "原子爆弾 / 水素爆弾の軌道の向き" }, "single_modal": { - "title": "シングルプレイヤー", + "title": "ソロ", "random_spawn": "ランダムスポーン", "allow_alliances": "同盟を許可", + "toggle_achievements": "実績の表示の切り替え", + "sign_in_for_achievements": "実績を確認するにはサインインしてください", "options_title": "オプション", "bots": "ボット数: ", "bots_disabled": "無効", @@ -145,6 +167,8 @@ "infinite_troops": "兵士無限", "compact_map": "小型マップ", "max_timer": "ゲーム時間 (分)", + "max_timer_placeholder": "分", + "max_timer_invalid": "適切な最大プレイ時間(1~120分)を入力してください", "disable_nukes": "核兵器使用禁止", "enables_title": "機能の有効化", "start": "ゲーム開始" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "アカウント", - "logged_in_as": "{email} としてログインしました", + "connected_as": "接続されたアカウント", + "stats_overview": "統計の概要", + "link_discord": "Discordアカウントを連携する", + "log_out": "ログアウト", + "sign_in_desc": "統計と進捗状況を保存するにはサインインしてください", + "or": "または", + "email_placeholder": "メールアドレスを入力してください", + "get_magic_link": "マジックリンクを入手", + "linked_account": "{account_name} としてログインしました", "fetching_account": "アカウント情報を取得中...", - "logged_in_with_discord": "Discordでログインしました", - "recovery_email_sent": "{email} に回復用のメールを送信しました" + "recovery_email_sent": "{email} に回復用のメールを送信しました", + "not_found": "見つかりません", + "clear_session": "セッションをクリア", + "failed_to_send_recovery_email": "再設定メールを送信できませんでした", + "enter_email_address": "メールアドレスを入力してください" }, "stats_modal": { "title": "ステータス", @@ -167,11 +202,40 @@ "loading": "ロード中…", "error": "クランステータスの読み込みに失敗しました", "no_stats": "クランステータスがありません", + "no_data_yet": "まだデータはありません", "clan": "クラン", "games": "ゲーム", "win_score": "勝利スコア", + "win_score_tooltip": "クランの参加と試合の難易度に基づいて重み付けされた勝利", "loss_score": "敗北スコア", - "win_loss_ratio": "勝利/敗北" + "loss_score_tooltip": "クランの参加と試合の難易度に基づいて重み付けされた敗北", + "win_loss_ratio": "勝利/敗北", + "ratio": "比率", + "rank": "ランク", + "try_again": "もう一度やり直してください" + }, + "game_info_modal": { + "title": "ゲームの詳細", + "players": "プレイヤー", + "atoms": "原子爆弾", + "hydros": "水素爆弾", + "mirv": "MIRV", + "bombs": "爆弾", + "total_gold": "合計", + "all_gold": "合計資金", + "trade": "貿易", + "conquest_gold": "征服したプレイヤーの資金数", + "stolen_gold": "戦艦で盗んだ資金", + "num_of_conquests": "征服したプレイヤーの数", + "duration": "間隔", + "survival_time": "生存時間", + "war": "戦争", + "economy": "経済", + "conquests": "征服", + "pirate": "海賊", + "conquered": "征服された", + "loading_game_info": "ゲームの統計を読み込んでいます", + "no_winner": "このゲームは勝者なしで終了しました(または国家が勝利しました)" }, "map": { "map": "地図", @@ -186,6 +250,7 @@ "asia": "アジア", "mars": "火星", "southamerica": "南アメリカ", + "britanniaclassic": "ブリタニア(クラシック)", "britannia": "ブリタニア", "gatewaytotheatlantic": "西ヨーロッパ", "australia": "オーストラリア", @@ -196,7 +261,7 @@ "betweentwoseas": "2つの海の間", "faroeislands": "フェロー諸島", "deglaciatedantarctica": "退氷した南極大陸", - "europeclassic": "ヨーロッパ (クラシック)", + "europeclassic": "ヨーロッパ(クラシック)", "falklandislands": "フォークランド諸島", "baikal": "バイカル湖付近", "halkidiki": "ハルキディキ半島", @@ -206,19 +271,33 @@ "yenisei": "エニセイ川", "pluto": "冥王星", "montreal": "モントリオール", + "newyorkcity": "ニューヨーク市", "achiran": "アチラン", "baikalnukewars": "バイカル(核戦争)", "fourislands": "4つの島", "gulfofstlawrence": "セントローレンス湾", - "lisbon": "リスボンの都市圏" + "lisbon": "リスボンの都市圏", + "svalmel": "スヴァルメル", + "manicouagan": "マニクアガン湖", + "lemnos": "レムノス島", + "sierpinski": "シェルピンスキー", + "twolakes": "二つの湖", + "straitofhormuz": "ホルムズ海峡", + "surrounded": "囲まれた島", + "didier": "ディディエ", + "didierfrance": "ディディエ(フランス)", + "amazonriver": "アマゾン川" }, "map_categories": { "continental": "大陸", "regional": "地域", - "fantasy": "その他" + "fantasy": "その他", + "special": "特殊マップ", + "arcade": "お楽しみマップ" }, "map_component": { - "loading": "読み込み中…" + "loading": "読み込み中…", + "error": "エラー" }, "private_lobby": { "title": "ランダム", @@ -229,42 +308,55 @@ "checking": "ロビーを確認中...", "not_found": "ロビーが見つかりません。IDを確認してもう一度お試しください。", "error": "エラーが発生しました。もう一度試すか、サポートにお問い合わせください。", - "joined_waiting": "参加に成功しました!ゲーム開始をお待ちください...", - "version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。" + "joined_waiting": "ロビーに参加しました!ホストの開始を待っています…", + "version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。", + "disabled_units": "無効ユニット" }, "public_lobby": { "join": "次のゲームに参加", "waiting": "人が参加しています...", - "teams_Duos": "2 人プレイヤー(ドゥオ)", - "teams_Trios": "3 人プレイヤー(トリオ)", - "teams_Quads": "4 人プレイヤー(クワッド)", - "teams_hvn": "プレイヤー対国家", + "teams_Duos": "{team_count}個の2人1組のチーム(デュオ)", + "teams_Trios": "{team_count}個の3人1組のチーム(トリオ)", + "teams_Quads": "{team_count}個の4人1組のチーム(クワッド)", + "waiting_for_players": "プレイヤーを待っています", + "starting_game": "ゲームを開始します...", + "teams_hvn": "人類 vs 国家", + "teams_hvn_detailed": "{num} 人類 vs {num} 国家", "teams": "{num}チーム", - "players_per_team": "{num}人プレイヤー" + "players_per_team": "{num}人プレイヤー", + "started": "開始しています" }, "matchmaking_modal": { - "title": "マッチングする", + "title": "1v1ランクマッチを作成 (アルファ版) ", "connecting": "サーバーに接続中…", "searching": "対戦相手を検索中…", - "waiting_for_game": "ゲーム開始を待っています…" + "waiting_for_game": "ゲーム開始を待っています…", + "elo": "あなたのELO: {elo}" }, "username": { "enter_username": "ユーザー名を入力", "not_string": "ユーザー名は文字列で入力してください。", "too_short": "ユーザー名は{min}文字より長い必要があります。", "too_long": "ユーザー名は{max}文字より短い必要があります。", - "invalid_chars": "ユーザー名には英字、数字、スペース、アンダースコア、および [角括弧] のみ使用できます。" + "invalid_chars": "ユーザー名には、文字、数字、スペース、アンダーバーのみを含めることができます。", + "tag": "タグ", + "tag_too_short": "クランタグは2〜5文字の英数字でなければなりません。", + "tag_invalid_chars": "クランタグには文字と数字のみを含めることができます。" }, "host_modal": { - "title": "プライベートロビー", + "title": "プライベートロビーを作成", + "label": "プライベート", "mode": "モード", "team_count": "チームの数", + "team_type": "チームタイプ", "options_title": "オプション", "bots": "ボット数: ", "bots_disabled": "無効", + "player_immunity_duration": "PVPの無敵時間(分)", "nations": "諸国: ", "disable_nations": "国家を無効化", "max_timer": "ゲーム時間 (分)", + "mins_placeholder": "分", "instant_build": "即時建築", "infinite_gold": "資金無限", "donate_gold": "資金援助", @@ -283,7 +375,11 @@ "assigned_teams": "チーム編成", "empty_teams": "空きのチーム", "empty_team": "空き", - "remove_player": "{username}を削除" + "remove_player": "{username}を削除", + "teams_Duos": "デュオ(2人1組)", + "teams_Trios": "トリオ(3人1組)", + "teams_Quads": "クワッド(4人1組)", + "teams_Humans Vs Nations": "人類 vs 国家" }, "team_colors": { "red": "赤", @@ -301,16 +397,20 @@ "code_license": "本ゲームのコードは AGPL-3.0 ライセンスに基づき公開されています(無保証)" }, "difficulty": { - "difficulty": "難易度", - "Easy": "簡単", - "Medium": "普通", - "Hard": "難しい", - "Impossible": "不可能" + "difficulty": "国家の難易度", + "easy": "簡単", + "medium": "普通", + "hard": "難しい", + "impossible": "不可能" }, "game_mode": { "ffa": "バトルロワイヤル", "teams": "チーム" }, + "public_game_modifier": { + "random_spawn": "ランダムスポーン", + "compact_map": "コンパクトマップ" + }, "select_lang": { "title": "言語を選択" }, @@ -327,7 +427,7 @@ "factory": "工場" }, "user_setting": { - "title": "ユーザー設定", + "title": "設定", "tab_basic": "基本設定", "tab_keybinds": "キーの割り当て", "dark_mode_label": "ダークモード", @@ -335,21 +435,23 @@ "emojis_label": "絵文字を表示", "emojis_desc": "ゲーム中で絵文字を表示します", "alert_frame_label": "アラートフレーム", - "alert_frame_desc": "警告フレームの表示をを切り替えます。有効時、裏切られたときや陸上から攻撃を受けたときにフレームが表示されます。", + "alert_frame_desc": "警告フレームの表示を切り替えます。有効時、裏切られたときや陸上から攻撃を受けたときにフレームが表示されます。", "special_effects_label": "特殊効果", "special_effects_desc": "特殊効果を切り替えます。無効にするとパフォーマンスが向上します。", "structure_sprites_label": "建物アイコン", "structure_sprites_desc": "建物アイコンの表示切替", + "cursor_cost_label_label": "カーソルの下に表示される建設コスト", + "cursor_cost_label_desc": "建物を建てる際にカーソルの下に必要資金を表示する", "anonymous_names_label": "ユーザー名を匿名にする", "anonymous_names_desc": "自分の画面では他のプレイヤーのユーザー名を非表示にし、代わりに別の名前で表示します。", "lobby_id_visibility_label": "ロビーIDを非表示", "lobby_id_visibility_desc": "プライベートロビー作成時にロビーIDを隠す", + "toggle_visibility": "表示を切り替え", "left_click_label": "左クリックでメニューを開く", "left_click_desc": "オンにすると左クリックでメニューを開くことができ、剣ボタンで攻撃します。オフにすると左クリックでそのまま攻撃します。", "left_click_menu": "左クリックでメニューを開く", "attack_ratio_label": "⚔️ 出撃兵力の比率", "attack_ratio_desc": "初期時点で出撃する兵力の割合を設定します(1–100%)", - "troop_ratio_desc": "初期時点で兵士と金を生産する労働者の割合を設定します(1–100%)", "territory_patterns_label": "🏳️ 領土の模様", "territory_patterns_desc": "ゲーム内で領土の模様を表示するかどうか", "performance_overlay_label": "パフォーマンスオーバーレイ", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "コードを書く速さを調節する(x1-x100)", "easter_bug_count_label": "バグの個数", "easter_bug_count_desc": "どのぐらいの個数のバグを許容できるか(0–1000個)", + "press_a_key": "キーを押す", "view_options": "表示オプション", "toggle_view": "表示切り替え", "toggle_view_desc": "国境を非表示にし、地形だけが見れます", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "選択した位置に水素爆弾を発射します。", "build_mirv": "MIRVを発射", "build_mirv_desc": "選択した位置にMIRVを発射します。", + "menu_shortcuts": "メニューのショートカット", + "build_menu_modifier": "ビルドメニューを表示", + "build_menu_modifier_desc": "ビルドメニューを開きます。", + "emoji_menu_modifier": "絵文字メニューを表示", + "emoji_menu_modifier_desc": "絵文字メニューを開きます。", "attack_ratio_controls": "攻撃比率の調整", "attack_ratio_up": "出撃兵力の割合を上げる", "attack_ratio_up_desc": "出撃兵力を10%増加させる", @@ -392,6 +500,8 @@ "boat_attack_desc": "カーソルの位置に合わせた土地にボート攻撃を送ります。", "ground_attack": "ボート攻撃", "ground_attack_desc": "カーソルの位置に合わせた土地にボート攻撃を送ります。", + "swap_direction": "核の撃つ向きを逆転", + "swap_direction_desc": "核の発射方向を切り替える(上方向/下方向)。", "zoom_controls": "ズーム操作", "zoom_out": "ズームアウト", "zoom_out_desc": "マップを縮小します", @@ -416,7 +526,8 @@ "exit_game_label": "ゲームから退出する", "exit_game_info": "メインメニューに戻ります", "background_music_volume": "BGM音量", - "sound_effects_volume": "効果音音量" + "sound_effects_volume": "効果音音量", + "keybind_conflict_error": "{key} キーはすでに他のアクションに使われています。" }, "chat": { "title": "クイックチャット", @@ -451,7 +562,7 @@ "mirv": "MIRVを[P1]に発射!", "focus": "[P1]に集中砲火だ!", "finish": "[P1]にとどめだ!", - "build_warships": "軍艦を建造せよ!" + "build_warships": "戦艦を建造せよ!" }, "defend": { "defend": "[P1]を守る!", @@ -513,7 +624,7 @@ "mirv": "指定したプレイヤーのみを狙う超大規模な爆発", "missile_silo": "核ミサイルの発射に使用される", "sam_launcher": "飛来する核ミサイルを迎撃する", - "warship": "貿易船を捕獲し、敵の船やボートを破壊する", + "warship": "貿易船を捕獲し、戦艦やボートを破壊する", "port": "貿易船を送って資金を獲得する", "defense_post": "近くの国境の防御を強化します", "city": "最大人口が増加します", @@ -529,6 +640,7 @@ "other_team": "{team}チームが勝利しました。", "you_won": "勝利!", "other_won": "{player}の勝利!", + "nation_won": "国家 {nation} が勝利しました!", "exit": "ゲームから退出", "keep": "観戦する", "spectate": "観戦する", @@ -537,7 +649,7 @@ "ofm_winter_description": "競技トーナメントにして、最強のプレイヤーたちに挑もう", "join_tournament": "トーナメントに参加", "join_discord": "Discordコミュニティに参加しよう!", - "discord_description": "他のプレイヤーと交流して、アップデート情報や戦略を共有しよう", + "discord_description": "プレイヤーとつながり、新しい機能を発見し、賞品を獲得しましょう!", "join_server": "サーバに入る", "youtube_tutorial": "ヘルプが必要ですか?" }, @@ -549,7 +661,7 @@ "team": "チーム", "owned": "領土", "gold": "ゴールド", - "troops": "兵士", + "maxtroops": "最大兵力", "launchers": "ランチャー", "sams": "SAM", "warships": "戦艦", @@ -565,6 +677,7 @@ "team": "チーム", "alliance_timeout": "同盟終了まで", "troops": "軍隊", + "maxtroops": "最大兵力", "a_troops": "攻撃兵士数", "gold": "資金", "ports": "港", @@ -575,7 +688,9 @@ "warships": "戦艦", "health": "体力", "attitude": "態度", - "levels": "レベル" + "levels": "レベル", + "wilderness_title": "荒野", + "irradiated_wilderness_title": "放射線に汚染された荒野" }, "events_display": { "retreating": "撤退中", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} が同盟の更新を提案しています", "ignore": "無視", "unit_voluntarily_deleted": "ユニットは自己破壊しました", - "betrayal_debuff_ends": "裏切りのデバフ終了まであと {time} 秒" + "betrayal_debuff_ends": "裏切りのデバフ終了まであと {time} 秒", + "attack_cancelled_retreat": "攻撃はキャンセルされました、撤退中に{troops} 人の兵士が死亡しました", + "received_gold_from_captured_ship": "{name} から捕獲した船から資金 {gold} を獲得しました", + "received_gold_from_trade": "{name} との貿易で資金 {gold}を獲得しました", + "missile_intercepted": "ミサイルが{unit}を迎撃しました", + "mirv_warheads_intercepted": "{count, plural, other {{count}発の MIRV 弾頭を迎撃}}", + "sent_troops_to_player": "{troops} の兵士を {name} に送信しました", + "received_troops_from_player": "{name}から{troops}の軍隊を受け取りました", + "sent_gold_to_player": "{gold} の資金を {name}に贈りました", + "received_gold_from_player": "{gold} から {name} の資金を受け取りました", + "unit_captured_by_enemy": "あなたの {unit} は {name}に鹵獲されました", + "captured_enemy_unit": "{unit}を{name}から奪い取りました", + "unit_destroyed": "あなたの{unit}は破壊されました", + "no_boats_available": "ボートをこれ以上出せません、最大は{max}隻までです" }, "unit_info_modal": { "structure_info": "建造物情報", @@ -653,7 +781,10 @@ "send_alliance": "同盟を要請", "send_troops": "軍隊を送信", "send_gold": "資金を送信", - "emotes": "絵文字" + "emotes": "絵文字", + "arc_up": "上向きの弧", + "arc_down": "下向きの弧", + "flip_rocket_trajectory": "ロケットの軌道を反転" }, "send_troops_modal": { "title_with_name": "{name}へ軍隊を送信", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "スタート地点を選んで下さい", - "random_spawn": "ランダムスポーンが有効です。開始地点を設定しています…" + "random_spawn": "ランダムスポーンが有効です。開始地点を設定しています…", + "singleplayer_game_paused": "ゲームを一時停止", + "multiplayer_game_paused": "ロビー作成者によってゲームが一時停止されました" }, "territory_patterns": { "title": "領土スキン", "colors": "色", "purchase": "購入", "show_only_owned": "自分の領地", + "all_owned": "すべてのスキンを手に入れました!新しいアイテムについては後ほどご確認ください。", + "not_logged_in": "ログインされていません", "blocked": { "login": "スキンを解放するにはログインしてください", "purchase": "スキンを解放するには購入してください" }, "pattern": { "default": "デフォルト" - } + }, + "select_skin": "スキンを選択", + "selected": "選択済" }, "flag_input": { "title": "旗を選択", @@ -786,8 +923,9 @@ "mode": "モード", "mode_ffa": "バトルロワイヤル", "mode_team": "チーム", - "view": "見る", + "replay": "リプレイ", "details": "詳細", + "ranking": "ランキング", "started": "既に開始", "map": "地図", "difficulty": "難易度", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "公開", "private": "非公開", - "singleplayer": "シングルプレイヤー", + "singleplayer": "ソロ", "mode": "モード", "stats_wins": "勝利数", "stats_losses": "敗北数", "stats_wlr": "勝敗比", "stats_games_played": "プレイ数", "mode_ffa": "デスマッチ", - "mode_team": "チーム" + "mode_team": "チーム", + "no_stats": "この選択に対する統計は記録されていません。" + }, + "matchmaking_button": { + "play_ranked": "1v1のランクマッチを作成", + "description": "(アルファ版)", + "login_required": "ランクマッチをプレイするにはログインしてください!", + "must_login": "ランクマッチをプレイするにはログインする必要があります。" } } diff --git a/resources/lang/metadata.json b/resources/lang/metadata.json index cfb9af301..4c1f989ae 100644 --- a/resources/lang/metadata.json +++ b/resources/lang/metadata.json @@ -101,6 +101,12 @@ "en": "Hungarian", "svg": "hu" }, + { + "code": "id", + "native": "Bahasa Indonesia", + "en": "Indonesian", + "svg": "id" + }, { "code": "it", "native": "Italiano", diff --git a/resources/lang/nl.json b/resources/lang/nl.json index 746150408..f8784c045 100644 --- a/resources/lang/nl.json +++ b/resources/lang/nl.json @@ -7,6 +7,7 @@ }, "common": { "close": "Sluiten", + "back": "Terug", "available": "Beschikbaar", "preset_max": "Max", "summary_send": "Verstuur", @@ -17,26 +18,42 @@ "cap_tooltip": "Resterende capaciteit ontvanger", "target_dead": "Doelwit uitgeschakeld", "target_dead_note": "Je kunt geen middelen sturen naar een dode speler.", - "none": "Geen" + "none": "Geen", + "copied": "Gekopieerd!", + "click_to_copy": "Klik om te kopiëren" }, "main": { "title": "OpenFront (ALFA)", "join_discord": "Discord", "login_discord": "Login met Discord", + "sign_in": "Aanmelden", + "discord_avatar_alt": "Avatar Discord profiel", + "user_avatar_alt": "Avatar van {username}", "checking_login": "Inlog controleren...", "logged_in": "Ingelogd!", "log_out": "Uitloggen", - "create_lobby": "Lobby aanmaken", - "join_lobby": "Lobby toetreden", - "single_player": "Eén Speler", + "create": "Lobby aanmaken", + "join": "Lobby toetreden", + "solo": "Solo", "instructions": "Instructies", + "game_info": "Spelinformatie", "wiki": "Wiki", "privacy_policy": "Privacybeleid", "terms_of_service": "Servicevoorwaarden", - "reddit": "Reddit" + "copyright": "© OpenFront™ en Bijdragers", + "reddit": "Reddit", + "play": "Spelen", + "news": "Nieuws", + "store": "Winkel", + "settings": "Instellingen", + "keys": "Sneltoetsen", + "stats": "Statistieken", + "account": "Account", + "help": "Help", + "menu": "Menu", + "pick_pattern": "Kies een skin!" }, "news": { - "see_all_releases": "Bekijk alle releases", "github_link": "op GitHub", "title": "Release-opmerkingen" }, @@ -83,6 +100,8 @@ "radial_attack": "Het aanvalsmenu openen.", "radial_info": "Infomenu openen.", "radial_boat": "Stuur een Boot (transportschip) voor een aanval op de geselecteerde locatie. Alleen beschikbaar als je toegang hebt tot water.", + "radial_donate_troops": "Doneer troepen, gelijk aan het percentage van de ingestelde aanvalsverhouding, aan de bondgenoot waarop je het radiale menu hebt geopend.", + "radial_donate_gold": "Opent het gouddonatiemenu zodat je bondgenoten snel goud kan sturen.", "radial_close": "Het menu sluiten.", "info_title": "Infomenu", "info_enemy_desc": "Bevat informatie zoals de naam van de geselecteerde speler, goud, troepen, of ze de handel hebben stopgezet, hoeveel kernwapens ze op je hebben afgevuurd, en of de speler een verrader is. Een verrader is een speler die een bondgenoot heeft aangevallen. Handel gestopt betekent dat jullie geen goud meer van elkaar ontvangen via handelsschepen. Handmatig (als de speler op \"Stop handel\" heeft geklikt, wat duurt totdat jullie beide op \"Start handel\" hebben geklikt) of automatisch (als jij jullie bondgenootschap hebt verraden, wat 5 minuten duurt of korter als jullie weer bondgenoten worden). Verrader toont 30 seconden lang Ja als de speler een bondgenoot heeft aangevallen. De iconen hieronder staan voor de volgende interacties:", @@ -94,8 +113,8 @@ "info_ally_panel": "Infopaneel bondgenoot", "info_ally_desc": "Wanneer je een bondgenootschap sluit met een speler, worden de volgende nieuwe iconen beschikbaar:", "ally_betray": "Verraad je bondgenoot, beëindig het bondgenootschap, stop de handel, verzwak je verdediging. De handel tussen jullie wordt 5 minuten gepauzeerd (of totdat jullie weer bondgenoten worden) en anderen stoppen de handel mogelijk ook. En tenzij de andere speler zelf een verrader was, wordt je 30 seconden als verrader gemarkeerd. Gedurende deze tijd staat er een icoon boven je naam en is je verdediging 50% zwakker. Bots zullen minder snel een bondgenoten willen worden en spelers zullen zich wel tweemaal bedenken voor ze dat doen.", - "ally_donate": "Geef een deel van je troepen aan je bondgenoot. Gebruikt wanneer ze weinig troepen hebben en worden aangevallen, of wanneer ze die extra kracht nodig hebben om een ​​vijand te verpletteren.", - "ally_donate_gold": "Geef een deel van je goud aan je bondgenoot. Wanneer zij weinig goud hebben en het voor gebouwen nodig hebben, of wanneer je teamgenoot aan het sparen is voor die MIRV.", + "ally_donate": "Doneer een deel van je troepen aan je bondgenoot. Gebruikt wanneer ze weinig troepen hebben en worden aangevallen, of wanneer ze die extra kracht nodig hebben om een ​​vijand te verpletteren.", + "ally_donate_gold": "Doneer een deel van je goud aan je bondgenoot. Wanneer zij weinig goud hebben en het voor gebouwen nodig hebben, of wanneer je teamgenoot aan het sparen is voor die MIRV.", "build_menu_title": "Bouwmenu", "build_menu_desc": "Maak hier een van of bekijk hoeveel van elke je al hebt gemaakt:", "build_name": "Naam", @@ -114,7 +133,7 @@ "build_silo": "Raketsilo", "build_silo_desc": "Maakt het lanceren van raketten mogelijk.", "build_sam": "Luchtdoelraket (SAM)-lanceerder", - "build_sam_desc": "Kan vijandelijke raketten onderscheppen binnen een straal van 80 pixels of, voor MIRV-kernkoppen, 50 pixels. Raakt 100% van de atoombommen, 80% van de waterstofbommen en 50% van de individuele MIRV-kernkoppen. De SAM heeft een afkoeltijd van 7,5 seconden.", + "build_sam_desc": "Kan vijandelijke raketten onderscheppen binnen een bereik van 100 pixels. De SAM-lanceerder heeft een herstelperiode van 7,5 seconden.", "build_atom": "Atoombom", "build_atom_desc": "Kleine explosieve bom die gebied, gebouwen, schepen en boten vernietigt. Komt vanuit de dichtstbijzijnde Raketsilo en landt op de plek waar je hebt geklikt om het te bouwen.", "build_hydrogen": "Waterstofbom", @@ -129,12 +148,15 @@ "icon_embargo": "Dollar stopbord - Embargo. Deze speler heeft de handel met jou gestopt, automatisch of handmatig.", "icon_request": "Envelop - Alliantieverzoek. Deze speler stuurde je een verzoek om bondgenoten te worden.", "info_enemy_panel": "Infopaneel vijand", - "exit_confirmation": "Weet je zeker dat je dit spel wilt verlaten?" + "exit_confirmation": "Weet je zeker dat je dit spel wilt verlaten?", + "bomb_direction": "Atoom- / waterstofbom boogrichting" }, "single_modal": { - "title": "Eén speler", + "title": "Solo", "random_spawn": "Willekeurige startpositie", "allow_alliances": "Bondgenootschappen toestaan", + "toggle_achievements": "Prestaties in- of uitschakelen", + "sign_in_for_achievements": "Meld je aan voor prestaties", "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", @@ -145,6 +167,8 @@ "infinite_troops": "Oneindige troepen", "compact_map": "Compacte kaart", "max_timer": "Spellengte (minuten)", + "max_timer_placeholder": "Min.", + "max_timer_invalid": "Voer een geldige max. timertijd in (1-120 minuten)", "disable_nukes": "Kernwapens uitschakelen", "enables_title": "Onderdelen inschakelen", "start": "Start Spel" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Account", - "logged_in_as": "Ingelogd als {email}", + "connected_as": "Gekoppeld als", + "stats_overview": "Overzicht van statistieken", + "link_discord": "Discord-account koppelen", + "log_out": "Uitloggen", + "sign_in_desc": "Meld je aan om statistieken en voortgang op te slaan", + "or": "OF", + "email_placeholder": "Voer je e-mailadres in", + "get_magic_link": "Krijg Magische Link", + "linked_account": "Ingelogd als {account_name}", "fetching_account": "Accountgegevens ophalen...", - "logged_in_with_discord": "Ingelogd met Discord", - "recovery_email_sent": "Herstelmail verzonden naar {email}" + "recovery_email_sent": "Herstelmail verzonden naar {email}", + "not_found": "Niet Gevonden", + "clear_session": "Sessie Wissen", + "failed_to_send_recovery_email": "Verzenden herstel e-mail mislukt", + "enter_email_address": "Voer een e-mailadres in alsjeblieft" }, "stats_modal": { "title": "Statistieken", @@ -167,11 +202,40 @@ "loading": "Laden...", "error": "Fout bij het laden van clan statistieken", "no_stats": "Er zijn geen clan statistieken beschikbaar", + "no_data_yet": "Nog geen gegevens", "clan": "Clan", "games": "Spellen", "win_score": "Win Score", + "win_score_tooltip": "Gewogen aantal overwinningen op basis van clandeelname en matchmoeilijkheidsgraad", "loss_score": "Verlies Score", - "win_loss_ratio": "Gewonnen/Verloren" + "loss_score_tooltip": "Gewogen aantal verliezen op basis van clandeelname en matchmoeilijkheidsgraad", + "win_loss_ratio": "Gewonnen/Verloren", + "ratio": "Verhouding", + "rank": "Rang", + "try_again": "Opnieuw Proberen" + }, + "game_info_modal": { + "title": "Spelinformatie", + "players": "Spelers", + "atoms": "Atoombom", + "hydros": "Waterstofbom", + "mirv": "MIRV", + "bombs": "Bommen", + "total_gold": "Totaal", + "all_gold": "Alle goud", + "trade": "Handel", + "conquest_gold": "Veroverd spelersgoud", + "stolen_gold": "Gestolen met oorlogsschepen", + "num_of_conquests": "Aantal veroverde spelers", + "duration": "Tijdsduur", + "survival_time": "Overlevingstijd", + "war": "Oorlog", + "economy": "Economie", + "conquests": "Veroveringen", + "pirate": "Kapen", + "conquered": "Veroverd", + "loading_game_info": "Spelstatistieken worden geladen", + "no_winner": "Dit spel eindigde zonder winnaar (of een Natie won)" }, "map": { "map": "Kaart", @@ -186,6 +250,7 @@ "asia": "Azië", "mars": "Mars", "southamerica": "Zuid-Amerika", + "britanniaclassic": "Britannia (Klassiek)", "britannia": "Groot-Brittanië", "gatewaytotheatlantic": "Poort van de Atlantische Oceaan", "australia": "Australië", @@ -196,7 +261,7 @@ "betweentwoseas": "Tussen twee zeeën", "faroeislands": "Faeröer eilanden", "deglaciatedantarctica": "Ontdooid Antarctica", - "europeclassic": "Europa (klassiek)", + "europeclassic": "Europa (Klassiek)", "falklandislands": "Falklandeilanden", "baikal": "Baikalmeer", "halkidiki": "Chalkidiki", @@ -206,19 +271,33 @@ "yenisei": "Jenisej", "pluto": "Pluto", "montreal": "Montreal", + "newyorkcity": "New York City", "achiran": "Achiran", "baikalnukewars": "Baikal (Kernoorlog)", "fourislands": "Vier Eilanden", "gulfofstlawrence": "Saint Lawrencebaai", - "lisbon": "Lissabon" + "lisbon": "Lissabon", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Limnos", + "sierpinski": "Sierpinski", + "twolakes": "Twee Meren", + "straitofhormuz": "Straat van Hormuz", + "surrounded": "Omringd", + "didier": "Didier", + "didierfrance": "Didier (Frankrijk)", + "amazonriver": "Amazonerivier" }, "map_categories": { "continental": "Continent", "regional": "Regio", - "fantasy": "Overig" + "fantasy": "Overig", + "special": "Speciaal", + "arcade": "Arcade" }, "map_component": { - "loading": "Laden..." + "loading": "Laden...", + "error": "Fout" }, "private_lobby": { "title": "Privélobby toetreden", @@ -229,47 +308,60 @@ "checking": "Lobby controleren...", "not_found": "Lobby niet gevonden. Controleer het ID en probeer het opnieuw.", "error": "Er is een fout opgetreden. Probeer het opnieuw of neem contact op met support.", - "joined_waiting": "Succesvol toegetreden! Wachten tot het spel begint...", - "version_mismatch": "Dit spel is aangemaakt met een andere versie. Kan niet afspelen of deelnemen." + "joined_waiting": "Toegetreden tot lobby! Wachten op host om te starten...", + "version_mismatch": "Dit spel is aangemaakt met een andere versie. Kan niet afspelen of deelnemen.", + "disabled_units": "Uitgeschakelde Eenheden" }, "public_lobby": { "join": "Deelnemen aan volgende Spel", "waiting": "spelers wachten", - "teams_Duos": "van 2 (Duo's)", - "teams_Trios": "van 3 (Trio's)", - "teams_Quads": "van 4 (Viertallen)", + "teams_Duos": "{team_count} Teams van 2 (Duo's)", + "teams_Trios": "{team_count} Teams van 3 (Trio's)", + "teams_Quads": "{team_count} Teams van 4 (Viertallen)", + "waiting_for_players": "Wachten op spelers", + "starting_game": "Spel starten…", "teams_hvn": "Mensen vs Naties", + "teams_hvn_detailed": "{num} Mensen vs {num} Naties", "teams": "{num} Teams", - "players_per_team": "van {num}" + "players_per_team": "van {num}", + "started": "Begonnen" }, "matchmaking_modal": { - "title": "Matchmaking", + "title": "1v1 Competitieve Matchmaking (ALFA)", "connecting": "Verbinden met matchmakingserver...", "searching": "Zoeken naar een spel...", - "waiting_for_game": "Wachten tot het spel begint..." + "waiting_for_game": "Wachten tot het spel begint...", + "elo": "Jouw ELO: {elo}" }, "username": { "enter_username": "Voer je gebruikersnaam in", "not_string": "Gebruikersnaam moet een tekenreeks zijn.", "too_short": "Gebruikersnaam moet minstens {min} tekens lang zijn.", "too_long": "Gebruikersnaam mag niet langer zijn dan {max} tekens.", - "invalid_chars": "Gebruikersnaam mag alleen letters, cijfers, spaties, underscores en [vierkante haakjes] bevatten." + "invalid_chars": "Gebruikersnaam kan alleen letters, cijfers, spaties en underscores bevatten.", + "tag": "TAG", + "tag_too_short": "Clantag moet 2-5 alfanumerieke tekens zijn.", + "tag_invalid_chars": "Clantag kan alleen letters en cijfers bevatten." }, "host_modal": { - "title": "Privélobby", + "title": "Privélobby Aanmaken", + "label": "Privé", "mode": "Modus", "team_count": "Aantal teams", + "team_type": "Teamtype", "options_title": "Opties", "bots": "Bots:", "bots_disabled": "Uitgeschakeld", + "player_immunity_duration": "PVP-immuniteitsduur (minuten)", "nations": "Naties: ", "disable_nations": "Naties uitschakelen", "max_timer": "Spellengte (minuten)", + "mins_placeholder": "Min.", "instant_build": "Bouwwachttijd uitschakelen", "infinite_gold": "Oneindig goud", - "donate_gold": "Goud geven", + "donate_gold": "Goud doneren", "infinite_troops": "Oneindige troepen", - "donate_troops": "Troepen geven", + "donate_troops": "Troepen doneren", "compact_map": "Compacte kaart", "enables_title": "Onderdelen inschakelen", "player": "Speler", @@ -283,7 +375,11 @@ "assigned_teams": "Toegewezen Teams", "empty_teams": "Lege Teams", "empty_team": "Leeg", - "remove_player": "Verwijder {username}" + "remove_player": "Verwijder {username}", + "teams_Duos": "Duo's (teams van 2)", + "teams_Trios": "Trio's (teams van 3)", + "teams_Quads": "Viertallen (teams van 4)", + "teams_Humans Vs Nations": "Mensen vs Naties" }, "team_colors": { "red": "Rood", @@ -301,16 +397,20 @@ "code_license": "Code gelicenseerd onder AGPL-3.0 (geen garantie)" }, "difficulty": { - "difficulty": "Moeilijkheidsgraad", - "Easy": "Ontspannen", - "Medium": "Gebalanceerd", - "Hard": "Intens", - "Impossible": "Onmogelijk" + "difficulty": "Natie moeilijkheidsgraad", + "easy": "Makkelijk", + "medium": "Gemiddeld", + "hard": "Moeilijk", + "impossible": "Onmogelijk" }, "game_mode": { "ffa": "Iedereen tegen elkaar (FFA)", "teams": "Teams" }, + "public_game_modifier": { + "random_spawn": "Willekeurige Startpositie", + "compact_map": "Compacte Kaart" + }, "select_lang": { "title": "Kies taal" }, @@ -327,7 +427,7 @@ "factory": "Fabriek" }, "user_setting": { - "title": "Gebruikersinstellingen", + "title": "Instellingen", "tab_basic": "Basisinstellingen", "tab_keybinds": "Sneltoetsen", "dark_mode_label": "Donkere Modus", @@ -340,16 +440,18 @@ "special_effects_desc": "Visuele effecten aanzetten. Zet uit om de prestaties van het spel te verbeteren", "structure_sprites_label": "Gebouw afbeeldingen", "structure_sprites_desc": "3D-afbeeldingen gebouwen in-/uitschakelen", + "cursor_cost_label_label": "Cursor Bouwkosten", + "cursor_cost_label_desc": "Toon kosten onder de bouwcursor", "anonymous_names_label": "Verborgen Namen", "anonymous_names_desc": "Vervang echte spelersnamen door willekeurige namen op je scherm.", "lobby_id_visibility_label": "Verborgen Lobby-ID's", "lobby_id_visibility_desc": "Verberg Lobby-ID tijdens het maken van een privélobby", + "toggle_visibility": "Zichtbaar/onzichtbaar", "left_click_label": "Linkermuisknop voor openen menu", "left_click_desc": "Als AAN: linkermuisknop opent het Radiale menu met zwaard-aanvalsknop. Als UIT: linkermuisknop opent direct de aanval.", "left_click_menu": "Linkermuisknop Radiale Menu", "attack_ratio_label": "⚔️ Aanvalsverhouding", "attack_ratio_desc": "Welk percentage van je troepen je bij een aanval stuurt (1-100%)", - "troop_ratio_desc": "De balans tussen troepen (voor gevechten) en werkers (voor goudproductie) aanpassen (1-100%)", "territory_patterns_label": "🏳️ Skins voor gebieden", "territory_patterns_desc": "Kies of je skins op gebieden wilt weergeven in het spel", "performance_overlay_label": "Prestatie-overlay", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "Pas aan hoe snel je pretendeert te programmeren (x1-x100)", "easter_bug_count_label": "Aantal bugs", "easter_bug_count_desc": "Hoeveel bugs je oké vindt (0-1000, gevoelsmatig)", + "press_a_key": "Druk op een toets", "view_options": "Weergave-opties", "toggle_view": "Weergave wisselen", "toggle_view_desc": "Weergave wisselen (terrein/landen)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Bouw een Waterstofbom onder je cursor.", "build_mirv": "Bouw MIRV", "build_mirv_desc": "Bouw een MIRV onder je cursor.", + "menu_shortcuts": "Menu sneltoetsen", + "build_menu_modifier": "Bouwmenu", + "build_menu_modifier_desc": "Houdt deze toets ingedrukt terwijl je klikt, om het bouwmenu te openen.", + "emoji_menu_modifier": "Emoji-menu", + "emoji_menu_modifier_desc": "Houdt deze toets ingedrukt terwijl je klikt, om het emoji-menu te openen.", "attack_ratio_controls": "Aanvalsverhouding-bediening", "attack_ratio_up": "Verhoog Aanvalsverhouding", "attack_ratio_up_desc": "Verhoog aanvalsverhouding met 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Stuur een bootaanval naar de plek onder je cursor.", "ground_attack": "Grondaanval", "ground_attack_desc": "Stuur een grondaanval naar de plek onder je cursor.", + "swap_direction": "Omdraaien boogrichting atoom- / waterstofbom", + "swap_direction_desc": "Draai boogrichting raket om (opwaarts/neerwaarts).", "zoom_controls": "Zoombediening", "zoom_out": "Uitzoomen", "zoom_out_desc": "Kaart uitzoomen", @@ -416,7 +526,8 @@ "exit_game_label": "Spel Verlaten", "exit_game_info": "Terug naar hoofdmenu", "background_music_volume": "Volume achtergrondmuziek", - "sound_effects_volume": "Volume geluidseffecten" + "sound_effects_volume": "Volume geluidseffecten", + "keybind_conflict_error": "De toets {key} is al verbonden aan een andere actie." }, "chat": { "title": "Snelchat", @@ -529,6 +640,7 @@ "other_team": "{team} team heeft gewonnen!", "you_won": "Je hebt gewonnen!", "other_won": "{player} heeft gewonnen!", + "nation_won": "Natie {nation} heeft gewonnen!", "exit": "Verlaat spel", "keep": "Blijf spelen", "spectate": "Toekijken", @@ -537,7 +649,7 @@ "ofm_winter_description": "Doe mee met het competitieve toernooi en concurreer met de beste spelers", "join_tournament": "Toernooi toetreden", "join_discord": "Word lid van onze Discord-gemeenschap!", - "discord_description": "Leg contact met andere spelers, krijg updates en deel strategieën", + "discord_description": "Maak contact met spelers, ontdek nieuwe functies en win prijzen!", "join_server": "Word lid van server", "youtube_tutorial": "Wat hulp nodig?" }, @@ -549,7 +661,7 @@ "team": "Team", "owned": "Bezit", "gold": "Goud", - "troops": "Troepen", + "maxtroops": "Max. troepen", "launchers": "Raketsilo's", "sams": "SAM-lanceerders", "warships": "Oorlogsschepen", @@ -565,6 +677,7 @@ "team": "Team", "alliance_timeout": "Alliantie eindigt over", "troops": "Troepen", + "maxtroops": "Max. troepen", "a_troops": "Aanvallende troepen", "gold": "Goud", "ports": "Havens", @@ -575,7 +688,9 @@ "warships": "Oorlogsschepen", "health": "Gezondheid", "attitude": "Houding", - "levels": "Levels" + "levels": "Levels", + "wilderness_title": "Wildernis", + "irradiated_wilderness_title": "Bestraalde Wildernis" }, "events_display": { "retreating": "trekken zich terug", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} wil jullie alliantie vernieuwen", "ignore": "Negeren", "unit_voluntarily_deleted": "Eenheid vrijwillig verwijderd", - "betrayal_debuff_ends": "Nog {time} seconden tot de verraad-verzwakking afloopt" + "betrayal_debuff_ends": "Nog {time} seconden tot de verraad-verzwakking afloopt", + "attack_cancelled_retreat": "Aanval geannuleerd, {troops} soldaten gedood tijdens terugtrekken", + "received_gold_from_captured_ship": "{gold} Goud ontvangen van veroverd schip van {name}", + "received_gold_from_trade": "{gold} Goud ontvangen van handel met {name}", + "missile_intercepted": "Raket onderschepte {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} MIRV-kernkop onderschept} other {{count} MIRV-kernkoppen onderschept}}", + "sent_troops_to_player": "{troops} Troepen naar {name} gestuurd", + "received_troops_from_player": "{troops} Troepen ontvangen van {name}", + "sent_gold_to_player": "{gold} Goud verstuurd aan {name}", + "received_gold_from_player": "{gold} Goud ontvangen van {name}", + "unit_captured_by_enemy": "Jouw {unit} werd veroverd door {name}", + "captured_enemy_unit": "{unit} veroverd van {name}", + "unit_destroyed": "Jouw {unit} werd vernietigd", + "no_boats_available": "Geen boten beschikbaar, max. {max}" }, "unit_info_modal": { "structure_info": "Gebouw Info", @@ -653,7 +781,10 @@ "send_alliance": "Stuur Alliantieverzoek", "send_troops": "Geef Troepen", "send_gold": "Geef Goud", - "emotes": "Emoji's" + "emotes": "Emoji's", + "arc_up": "Opwaartse boog", + "arc_down": "Neerwaartse boog", + "flip_rocket_trajectory": "Rakettraject spiegelen" }, "send_troops_modal": { "title_with_name": "Stuur Troepen naar {name}", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "Kies een startlocatie", - "random_spawn": "Willekeurige startpositie is ingeschakeld. Positie wordt voor je gekozen..." + "random_spawn": "Willekeurige startpositie is ingeschakeld. Positie wordt voor je gekozen...", + "singleplayer_game_paused": "Spel gepauzeerd", + "multiplayer_game_paused": "Spel gepauzeerd door Lobby-maker" }, "territory_patterns": { "title": "Skins ", "colors": "Kleuren", "purchase": "Kopen", "show_only_owned": "Mijn Skins", + "all_owned": "Je bezit alle skins! Kom later terug voor nieuwe items.", + "not_logged_in": "Niet ingelogd", "blocked": { "login": "Je moet ingelogd zijn voor toegang tot deze skin.", "purchase": "Koop deze skin om te ontgrendelen." }, "pattern": { "default": "Standaard" - } + }, + "select_skin": "Kies Skin", + "selected": "geselecteerd" }, "flag_input": { "title": "Selecteer Vlag", @@ -747,7 +884,7 @@ "ship_type": "Scheepstype", "weapon": "Wapen", "built": "Gebouwd", - "destroyed": "Verwoest", + "destroyed": "Vernietigd", "captured": "Veroverd", "lost": "Verloren", "hits": "Treffers", @@ -786,8 +923,9 @@ "mode": "Modus", "mode_ffa": "Iedereen tegen elkaar", "mode_team": "Team", - "view": "Weergeven", + "replay": "Herhaling", "details": "Details", + "ranking": "Rang", "started": "Begonnen", "map": "Kaart", "difficulty": "Moeilijkheidsgraad", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Openbaar", "private": "Privé", - "singleplayer": "Eén Speler", + "singleplayer": "Solo", "mode": "Modus", "stats_wins": "Overwinningen", "stats_losses": "Nederlagen", "stats_wlr": "Winst:verliesverhouding", "stats_games_played": "Gespeelde spellen", "mode_ffa": "Iedereen tegen elkaar", - "mode_team": "Team" + "mode_team": "Team", + "no_stats": "Geen statistieken vastgelegd voor deze selectie." + }, + "matchmaking_button": { + "play_ranked": "1v1 Competitieve Matchmaking", + "description": "(ALFA)", + "login_required": "Log in om competitief te spelen!", + "must_login": "Je moet ingelogd zijn om competitieve matchmaking te spelen." } } diff --git a/resources/lang/ru.json b/resources/lang/ru.json index ce7d1cfe6..89e9daf93 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -7,6 +7,7 @@ }, "common": { "close": "Закрыть", + "back": "Назад", "available": "Доступно", "preset_max": "Максимум", "summary_send": "Перевод", @@ -17,26 +18,42 @@ "cap_tooltip": "Оставшаяся ёмкость получателя", "target_dead": "Цель устранена", "target_dead_note": "Невозможно отправить ресурсы устранённому игроку.", - "none": "Ничего" + "none": "Ничего", + "copied": "Скопировано!", + "click_to_copy": "Нажмите, чтобы скопировать" }, "main": { "title": "OpenFront (АЛЬФА)", "join_discord": "Discord", "login_discord": "Войти через Discord", + "sign_in": "Войти", + "discord_avatar_alt": "Аватар профиля Discord", + "user_avatar_alt": "Аватар {username}", "checking_login": "Проверка авторизации...", "logged_in": "Вход выполнен!", "log_out": "Выйти", - "create_lobby": "Создать лобби", - "join_lobby": "Присоединиться к лобби", - "single_player": "Одиночная игра", + "create": "Создать лобби", + "join": "Присоединиться к лобби", + "solo": "Соло", "instructions": "Инструкции", + "game_info": "Информация об игре", "wiki": "Вики", "privacy_policy": "Политика конфиденциальности", "terms_of_service": "Пользовательское соглашение", - "reddit": "Reddit" + "copyright": "© OpenFront™ и участники", + "reddit": "Reddit", + "play": "Играть", + "news": "Новости", + "store": "Магазин", + "settings": "Настройки", + "keys": "Клавиши", + "stats": "Статистика", + "account": "Аккаунт", + "help": "Помощь", + "menu": "Меню", + "pick_pattern": "Выберите узор!" }, "news": { - "see_all_releases": "Посмотреть все выпуски", "github_link": "на GitHub", "title": "Список изменений" }, @@ -66,7 +83,7 @@ "ui_events": "Панель событий", "ui_events_desc": "Панель событий отображает последние события, запросы и сообщения быстрого чата. Некоторые примеры:", "ui_events_alliance": "Союз — Запросы на заключение союзов можно принимать или отклонять. Союзники могут обмениваться ресурсами и войсками, но не могут атаковать друг друга. Нажатие на «Осмотреть» перемещает вид на игрока, который отправил запрос.", - "ui_events_attack": "Атаки — Отображение входящих и исходящих атак. Нажмите на сообщение, чтобы центровать камеру на атаку, ракету или лодку (транспортный корабль). Вы можете отозвать войска, нажав на красную кнопку «X». Это будет стоить жизней 25% войск, которые атакуют. Если вы отозвёте лодку, она вернётся в исходное местоположение и совершит атаку, если территория была захвачена. Ракеты нельзя отозвать после запуска.", + "ui_events_attack": "Атаки — Отображение входящих и исходящих атак. Нажмите на сообщение, чтобы центровать камеру на атаку, ракету или лодку (транспортный корабль). Вы можете отозвать войска, нажав на красную кнопку «X». Это будет стоить жизней 25% войск, которые атакуют. Если вы отозвёте судо, оно вернётся в исходное местоположение и совершит атаку, если территория была захвачена. Ракеты нельзя отозвать после запуска.", "ui_events_quickchat": "Быстрый чат — Здесь вы можете увидеть отправленные и полученные сообщения. Отправьте сообщение игроку, нажав на значок быстрого чата в его меню информации.", "ui_options": "Настройки", "ui_options_desc": "Среди них можно найти следующие элементы:", @@ -76,13 +93,15 @@ "option_pause": "Приостановить/Продолжить игру — Доступно только в режиме одиночной игры.", "option_timer": "Таймер — Время, прошедшее с начала игры.", "option_exit": "Кнопка выхода.", - "option_settings": "Настройки — Открыть меню настроек. В нём вы можете включить/выключить альтернативное представление, эмодзи, тёмный режим, ниндзя (режим скрытых/случайных имён) и взаимодействие левой кнопкой мыши.", + "option_settings": "Настройки — Открыть меню настроек. В нём вы можете переключить альтернативное представление, эмодзи, тёмный режим, ниндзя (режим скрытых/случайных имён) и взаимодействие левой кнопкой мыши.", "radial_title": "Круговое меню", "radial_desc": "Щелчок правой кнопкой мыши (или нажатие на мобильном устройстве) открывает круговое меню. Щёлкните правой кнопкой мыши за его пределами, чтобы закрыть его. С этого меню вы можете:", "radial_build": "Открыть меню строительства.", "radial_attack": "Открыть меню атаки.", "radial_info": "Открыть меню информации.", - "radial_boat": "Отправить лодку (транспортный корабль) для атаки указанного места. Доступно только при наличии доступа к воде.", + "radial_boat": "Отправить судно (транспортный корабль) для атаки указанного места. Доступно только при наличии доступа к воде.", + "radial_donate_troops": "Пожертвовать войска, равные соотношению вашего ползунка атаки тому союзнику, на котором вы открыли круговое меню.", + "radial_donate_gold": "Открывает меню ползунка пожертвования золота для быстрой отправки золота союзникам.", "radial_close": "Закрыть меню.", "info_title": "Меню информации", "info_enemy_desc": "Содержит такую информацию о выбранном игроке, как его имя, количество золота, войск, состояние торговли с вами, запущенные на вас ракеты и метку предателя. Прекращённая торговля значит, что вы не будете получать от игрока золото и он не будет отправлять вам золото через торговые корабли. Вручную (если игрок нажал «Прекратить торговлю», что длится до тех пор, пока вы оба не нажмёте «Начать торговлю») или автоматически (если вы предали ваш союз, что длится до тех пор, пока вы не станете союзниками снова или через 5 минут). В поле «Предатель» будет указана метка «Да» в течение 30 секунд после того, как игрок предал и напал на игрока, который был в союзе с ними. Значки ниже обозначают следующие взаимодействия:", @@ -110,12 +129,12 @@ "build_port": "Порт", "build_port_desc": "Может быть построен только вблизи воды. Позволяет строить военные корабли. Автоматически посылает торговые суда между портами вашей и других стран (за исключением случаев, когда торговля прекращена), выдавая золото обеим сторонам. Торговля прекращается автоматически если вы атакуете или атакуют вас. Возобновляется через 5 минут или если вы становитесь союзниками. Вы можете вручную управлять торговлей с помощью кнопок «Прекратить торговлю» и «Начать торговлю».", "build_warship": "Военный корабль", - "build_warship_desc": "Патрулирует территорию, захватывая вражеские торговые корабли и разрушая вражеские лодки (транспортные корабли) и военные корабли. Появляется из ближайшего порта и патрулирует область, выбранную нажатием кнопкой мыши при создании. Вы можете управлять военными кораблями при помощью кнопки атаки (см. действие «Атака» в разделе «Горячие клавиши»): сначала нажмите на корабль, а затем — на новую область, к которой вы хотите переместиться.", + "build_warship_desc": "Патрулирует территорию, захватывая вражеские торговые корабли и разрушая вражеские суда (транспортные корабли) и военные корабли. Появляется из ближайшего порта и патрулирует область, выбранную нажатием кнопкой мыши при создании. Вы можете управлять военными кораблями при помощью кнопки атаки (см. действие «Атака» в разделе «Горячие клавиши»): сначала нажмите на корабль, а затем — на новую область, к которой вы хотите переместиться.", "build_silo": "Ракетная шахта", "build_silo_desc": "Позволяет запускать ракеты.", - "build_sam": "Пусковая установка ЗРК", - "build_sam_desc": "Позволяет перехватывать вражеские ракеты в радиусе 100 пикселей. Имеет шанс 100% на попадание в атомную бомбу, 80% — в водородную бомбу и 50% — в отдельные боеголовки РГЧ ИН. Перезарядка ЗРК составляет 7,5 секунды.", - "build_atom": "Атомная бомба", + "build_sam": "ПУ ЗРК", + "build_sam_desc": "Может перехватывать вражеские ракеты в радиусе 100 пикселей. ЗРК имеет период перезарядки в 7,5 секунд.", + "build_atom": "Ядерная бомба", "build_atom_desc": "Небольшая взрывная бомба, которая разрушает территорию, сооружения, корабли и лодки. Запускается из ближайшей ракетной шахты и наносит удар по области, выбранной нажатием кнопкой мыши.", "build_hydrogen": "Водородная бомба", "build_hydrogen_desc": "Большая взрывная бомба. Запускается из ближайшей ракетной шахты и наносит удар по области, выбранной нажатием кнопкой мыши.", @@ -129,12 +148,15 @@ "icon_embargo": "Перечёркнутый знак доллара — Эмбарго. Этот игрок перестал торговать с вами; автоматически или вручную.", "icon_request": "Конверт — Запрос на союз. Этот игрок отправил вам запрос на заключение союза.", "info_enemy_panel": "Панель информации о враге", - "exit_confirmation": "Вы уверены, что хотите выйти из игры?" + "exit_confirmation": "Вы уверены, что хотите выйти из игры?", + "bomb_direction": "Траектория полёта ядерной/водородной бомбы" }, "single_modal": { - "title": "Одиночная игра", + "title": "Соло", "random_spawn": "Случайное появление", "allow_alliances": "Разрешить союзы", + "toggle_achievements": "Переключение достижений", + "sign_in_for_achievements": "Войдите, чтобы получать достижения", "options_title": "Настройки", "bots": "Боты: ", "bots_disabled": "Отключены", @@ -145,6 +167,8 @@ "infinite_troops": "Неограниченные войска", "compact_map": "Компактная карта", "max_timer": "Продолжительность игры (минуты)", + "max_timer_placeholder": "Минуты", + "max_timer_invalid": "Пожалуйста, введите допустимое максимальное значение таймера (1–120 минут)", "disable_nukes": "Отключить бомбы", "enables_title": "Разрешения", "start": "Начать игру" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Аккаунт", - "logged_in_as": "Вы вошли как {email}", + "connected_as": "Вы вошли как", + "stats_overview": "Обзор статистики", + "link_discord": "Привязать учётную запись Discord", + "log_out": "Выйти", + "sign_in_desc": "Войдите, чтобы сохранить статистику и прогресс", + "or": "ИЛИ", + "email_placeholder": "Введите свою почту", + "get_magic_link": "Получить волшебную ссылку", + "linked_account": "Вы вошли как {account_name}", "fetching_account": "Получение информации об аккаунте...", - "logged_in_with_discord": "Вы вошли через Discord", - "recovery_email_sent": "Письмо для восстановления отправлено на {email}" + "recovery_email_sent": "Письмо для восстановления отправлено на {email}", + "not_found": "Не найдено", + "clear_session": "Очистить сессию", + "failed_to_send_recovery_email": "Не удалось отправить письмо для восстановления", + "enter_email_address": "Пожалуйста, введите адрес электронной почты" }, "stats_modal": { "title": "Статистика", @@ -167,11 +202,40 @@ "loading": "Загрузка...", "error": "Ошибка загрузки статистики кланов", "no_stats": "Статистика кланов недоступна", + "no_data_yet": "Пока нет данных", "clan": "Клан", "games": "Игры", "win_score": "Счёт побед", + "win_score_tooltip": "Взвешенные победы на основе участия клана и сложности матча", "loss_score": "Счёт поражений", - "win_loss_ratio": "Победы/Поражения" + "loss_score_tooltip": "Взвешенные поражения на основе участия клана и сложности матча", + "win_loss_ratio": "Победы/Поражения", + "ratio": "Соотношение", + "rank": "Ранг", + "try_again": "Попробуйте ещё раз" + }, + "game_info_modal": { + "title": "Информация об игре", + "players": "Игроки", + "atoms": "Ядерные бомбы", + "hydros": "Водородные бомбы", + "mirv": "РГЧ ИН", + "bombs": "Бомбы", + "total_gold": "Всего", + "all_gold": "Всё золото", + "trade": "Торговля", + "conquest_gold": "Захваченное золото игроков", + "stolen_gold": "Украдено с помощью военных кораблей", + "num_of_conquests": "Количество покорённых игроков", + "duration": "Продолжительность", + "survival_time": "Время выживания", + "war": "Война", + "economy": "Экономика", + "conquests": "Завоевания", + "pirate": "Пиратство", + "conquered": "Завоёвано", + "loading_game_info": "Загрузка игровой статистики", + "no_winner": "Эта игра закончилась без победителя (или выиграла нация)" }, "map": { "map": "Карта", @@ -186,6 +250,7 @@ "asia": "Азия", "mars": "Марс", "southamerica": "Южная Америка", + "britanniaclassic": "Британия (классическая)", "britannia": "Британия", "gatewaytotheatlantic": "Гибралтарский пролив", "australia": "Австралия", @@ -206,22 +271,36 @@ "yenisei": "Енисей", "pluto": "Плутон", "montreal": "Монреаль", + "newyorkcity": "Нью-Йорк", "achiran": "Акиран", "baikalnukewars": "Байкал (ядерные войны)", "fourislands": "Четыре острова", "gulfofstlawrence": "Залив Св. Лоуренса", - "lisbon": "Лиссабон" + "lisbon": "Лиссабон", + "svalmel": "Свалмель", + "manicouagan": "Маникуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпинский", + "twolakes": "Два озера", + "straitofhormuz": "Ормузский пролив", + "surrounded": "Окружение", + "didier": "Дидье", + "didierfrance": "Дидье (Франция)", + "amazonriver": "Река Амазонка" }, "map_categories": { "continental": "Континентальные", "regional": "Региональные", - "fantasy": "Прочие" + "fantasy": "Прочие", + "special": "Особые", + "arcade": "Аркадные" }, "map_component": { - "loading": "Загрузка..." + "loading": "Загрузка...", + "error": "Ошибка" }, "private_lobby": { - "title": "Присоединиться к приватному лобби", + "title": "Присоединение к приватному лобби", "enter_id": "Введите ID лобби", "player": "Игрок", "players": "Игрока(-ов)", @@ -229,42 +308,55 @@ "checking": "Проверка лобби...", "not_found": "Лобби не найдено. Пожалуйста, проверьте правильность ID и попробуйте ещё раз.", "error": "Произошла ошибка. Пожалуйста, попробуйте ещё раз или обратитесь в службу поддержки.", - "joined_waiting": "Вы успешно присоединились! Ожидание начала игры...", - "version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться." + "joined_waiting": "Лобби подключено! Ждём, пока хост начнёт игру...", + "version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться.", + "disabled_units": "Отключённые сооружения" }, "public_lobby": { "join": "Присоединиться к следующей игре", "waiting": "игрока(-ов) в ожидании", - "teams_Duos": "по 2 (дуо)", - "teams_Trios": "по 3 (трио)", - "teams_Quads": "по 4 (квады)", + "teams_Duos": "{team_count} команды по 2 (дуо)", + "teams_Trios": "{team_count} команды по 3 (трио)", + "teams_Quads": "{team_count} команды по 4 (квады)", + "waiting_for_players": "Ожидание игроков", + "starting_game": "Запуск игры…", "teams_hvn": "Люди против наций", + "teams_hvn_detailed": "{num} людей против {num} наций", "teams": "Команд: {num}", - "players_per_team": "по {num}" + "players_per_team": "по {num}", + "started": "Начато" }, "matchmaking_modal": { - "title": "Подбор игроков", + "title": "Рейтинговый подбор 1v1 (АЛЬФА)", "connecting": "Подключение к серверу подбора игроков...", "searching": "Поиск игры...", - "waiting_for_game": "Ожидание начала игры..." + "waiting_for_game": "Ожидание начала игры...", + "elo": "Ваш ELO: {elo}" }, "username": { "enter_username": "Введите своё имя игрока", "not_string": "Имя игрока должно быть строкой.", "too_short": "Имя игрока должно содержать не менее {min} символов.", "too_long": "Имя игрока не должно превышать {max} символов.", - "invalid_chars": "Имя игрока может содержать только латинские буквы, цифры, пробелы, подчёркивания и [квадратные скобки]." + "invalid_chars": "Имя игрока может содержать только латинские буквы, цифры, пробелы и подчёркивания.", + "tag": "ТЕГ", + "tag_too_short": "Тег клана должен состоять из 2–5 буквенно-цифровых символов.", + "tag_invalid_chars": "Тег клана может содержать только латинские буквы и цифры." }, "host_modal": { - "title": "Приватное лобби", + "title": "Создание приватного лобби", + "label": "Приватный", "mode": "Режим", "team_count": "Количество команд", + "team_type": "Тип команды", "options_title": "Настройки", "bots": "Боты: ", "bots_disabled": "Отключены", + "player_immunity_duration": "Продолжительность иммунитета в PVP (минуты)", "nations": "Нации: ", "disable_nations": "Отключить нации", "max_timer": "Продолжительность игры (минуты)", + "mins_placeholder": "Минуты", "instant_build": "Мгновенная стройка", "infinite_gold": "Неограниченное золото", "donate_gold": "Пожертвование золота", @@ -283,7 +375,11 @@ "assigned_teams": "Распределённые команды", "empty_teams": "Пустые команды", "empty_team": "Пусто", - "remove_player": "Удалить {username}" + "remove_player": "Удалить {username}", + "teams_Duos": "Дуо (команды по 2)", + "teams_Trios": "Трио (команды по 3)", + "teams_Quads": "Квады (команды по 4)", + "teams_Humans Vs Nations": "Люди против наций" }, "team_colors": { "red": "Красный", @@ -301,18 +397,22 @@ "code_license": "Код лицензирован согласно AGPL-3.0 (без гарантий)" }, "difficulty": { - "difficulty": "Сложность", - "Easy": "Расслабленная", - "Medium": "Уравновешенная", - "Hard": "Напряжённая", - "Impossible": "Невозможная" + "difficulty": "Сложность наций", + "easy": "Легко", + "medium": "Средне", + "hard": "Сложно", + "impossible": "Невозможно" }, "game_mode": { "ffa": "Каждый против каждого (FFA)", "teams": "Команды" }, + "public_game_modifier": { + "random_spawn": "Случайное появления", + "compact_map": "Компактная карта" + }, "select_lang": { - "title": "Выберите язык" + "title": "Выбор языка" }, "unit_type": { "city": "Город", @@ -320,44 +420,47 @@ "port": "Порт", "warship": "Военный корабль", "missile_silo": "Ракетная шахта", - "sam_launcher": "Пусковая установка ЗРК", - "atom_bomb": "Атомная бомба", + "sam_launcher": "ПУ ЗРК", + "atom_bomb": "Ядерная бомба", "hydrogen_bomb": "Водородная бомба", "mirv": "РГЧ ИН", "factory": "Фабрика" }, "user_setting": { - "title": "Пользовательские настройки", + "title": "Настройки", "tab_basic": "Основные настройки", "tab_keybinds": "Привязки клавиш", "dark_mode_label": "Тёмный режим", "dark_mode_desc": "Переключение внешнего вида сайта между светлой и тёмной темой", "emojis_label": "Эмодзи", - "emojis_desc": "Включение/выключение видимости эмодзи в игре", + "emojis_desc": "Переключить видимость эмодзи в игре", "alert_frame_label": "Рамка тревоги", - "alert_frame_desc": "Включить/выключить рамку тревоги. Когда включено, она будет отображаться, когда вас предают или атакуют по суше.", + "alert_frame_desc": "Переключить рамку тревоги. При включении рамка будет отображаться, когда вас предают или атакуют по суше.", "special_effects_label": "Спецэффекты", - "special_effects_desc": "Включить/выключить спецэффекты. Отключите для улучшения производительности", + "special_effects_desc": "Переключить спецэффекты. Отключите для улучшения производительности", "structure_sprites_label": "Спрайты структур", - "structure_sprites_desc": "Включение/выключение спрайтов структур", + "structure_sprites_desc": "Переключить спрайты структур", + "cursor_cost_label_label": "Цена постройки под указателем", + "cursor_cost_label_desc": "Показывать цену постройки под указателем", "anonymous_names_label": "Скрытые имена", "anonymous_names_desc": "Скрыть настоящие имена игроков и заменить их случайными.", "lobby_id_visibility_label": "Скрытые ID лобби", "lobby_id_visibility_desc": "Скрыть ID при создании приватного лобби", + "toggle_visibility": "Переключение видимости", "left_click_label": "Открытие меню левой кнопкой мыши", "left_click_desc": "ВКЛЮЧЕНО: щелчок левой кнопкой мыши открывает меню, атака совершается кнопкой с мечом. ВЫКЛЮЧЕНО: нажатие левой кнопкой мыши совершает атаку напрямую.", "left_click_menu": "Меню на левую кнопку мыши", "attack_ratio_label": "⚔️ Соотношение атаки", "attack_ratio_desc": "Какой процент ваших войск отправлять в бой (1–100%)", - "troop_ratio_desc": "Настройте соотношение между войсками (для боя) и рабочими (для добычи золота) (1–100%)", "territory_patterns_label": "🏳️ Скины территории", "territory_patterns_desc": "Выберите, показывать ли скины территорий в игре", "performance_overlay_label": "Оверлей производительности", - "performance_overlay_desc": "Включить/выключить оверлей производительности. Если включено, будет отображаться оверлей производительности. Нажмите Shift+D во время игры для включения/выключения.", + "performance_overlay_desc": "Переключить оверлей производительности. При включении будет показан оверлей производительности. Нажмите Shift+D во время игры для переключения.", "easter_writing_speed_label": "Множитель скорости печати", "easter_writing_speed_desc": "Настройте скорость, с которой вы делаете вид, что программируете (x1–x100)", "easter_bug_count_label": "Количество багов", "easter_bug_count_desc": "Количество багов, которое вы считаете приемлемым (0–1000, эмоционально)", + "press_a_key": "Нажмите клавишу", "view_options": "Настройки просмотра", "toggle_view": "Переключить представление", "toggle_view_desc": "Альтернативное представление (рельеф/страны)", @@ -374,24 +477,31 @@ "build_warship_desc": "Разместить военный корабль под указателем.", "build_missile_silo": "Разместить ракетную шахту", "build_missile_silo_desc": "Разместить ракетную шахту под указателем.", - "build_sam_launcher": "Разместить установку ЗРК", - "build_sam_launcher_desc": "Разместить установку ЗРК под указателем.", + "build_sam_launcher": "Разместить ПУ ЗРК", + "build_sam_launcher_desc": "Разместить ПУ ЗРК под указателем.", "build_atom_bomb": "Разместить ядерную бомбу", "build_atom_bomb_desc": "Разместить ядерную бомбу под указателем.", "build_hydrogen_bomb": "Разместить водородную бомбу", "build_hydrogen_bomb_desc": "Разместить водородную бомбу под указателем.", "build_mirv": "Разместить РГЧ ИН", "build_mirv_desc": "Разместить РГЧ ИН под указателем.", + "menu_shortcuts": "Горячие клавиши меню", + "build_menu_modifier": "Модификатор меню строительства", + "build_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню строительства.", + "emoji_menu_modifier": "Модификатор меню эмодзи", + "emoji_menu_modifier_desc": "Удерживайте эту клавишу при нажатии, чтобы открыть меню эмодзи.", "attack_ratio_controls": "Управление соотношением атаки", "attack_ratio_up": "Увеличить соотношение атаки", "attack_ratio_up_desc": "Увеличить соотношение атаки на 10%", "attack_ratio_down": "Уменьшить соотношение атаки", "attack_ratio_down_desc": "Уменьшить соотношение атаки на 10%", "attack_keybinds": "Привязки клавиш атаки", - "boat_attack": "Атака лодкой", + "boat_attack": "Атака судом", "boat_attack_desc": "Отправить атаку лодкой на ячейку под указателем.", "ground_attack": "Наземная атака", "ground_attack_desc": "Отправить наземную атаку на ячейку под указателем.", + "swap_direction": "Поменять направление ракеты", + "swap_direction_desc": "Переключить направление ракеты (вверх/вниз).", "zoom_controls": "Масштабирование", "zoom_out": "Отдалить", "zoom_out_desc": "Отдалить карту", @@ -412,11 +522,12 @@ "unbind": "Освободить", "on": "Включено", "off": "Выключено", - "toggle_terrain": "Включение/выключение рельефа", + "toggle_terrain": "Переключить рельеф", "exit_game_label": "Выйти из игры", "exit_game_info": "Вернуться в главное меню", "background_music_volume": "Громкость фоновой музыки", - "sound_effects_volume": "Громкость звуковых эффектов" + "sound_effects_volume": "Громкость звуковых эффектов", + "keybind_conflict_error": "Клавиша {key} уже привязана к другому действию." }, "chat": { "title": "Быстрый чат", @@ -512,7 +623,7 @@ "hydrogen_bomb": "Большой взрыв", "mirv": "Огромный взрыв, нацеленный только на выбранного игрока", "missile_silo": "Используется для запуска ракет", - "sam_launcher": "Защищает от атомных ракет", + "sam_launcher": "Защищает от ядерных ударов", "warship": "Захватывает торговые суда, уничтожает суда и лодки", "port": "Отправляет торговые корабли для генерации золота", "defense_post": "Укрепляет защиту ближайших границ", @@ -529,6 +640,7 @@ "other_team": "Команда «{team}» победила!", "you_won": "Вы победили!", "other_won": "Игрок {player} победил!", + "nation_won": "Нация {nation} победила!", "exit": "Выйти из игры", "keep": "Продолжить игру", "spectate": "Наблюдать", @@ -537,7 +649,7 @@ "ofm_winter_description": "Присоединяйтесь к турниру и состязайтесь с лучшими игроками", "join_tournament": "Присоединиться к турниру", "join_discord": "Присоединяйтесь к нашему сообществу в Discord!", - "discord_description": "Связывайтесь с другими игроками, получайте новости и делитесь стратегиями", + "discord_description": "Связывайтесь с игроками, открывайте новые возможности и выигрывайте призы!", "join_server": "Присоединиться к серверу", "youtube_tutorial": "Нужна помощь?" }, @@ -549,7 +661,7 @@ "team": "Команда", "owned": "Территории", "gold": "Золото", - "troops": "Войска", + "maxtroops": "Максимум войск", "launchers": "Установки", "sams": "ЗРК", "warships": "Военные корабли", @@ -565,6 +677,7 @@ "team": "Команда", "alliance_timeout": "Конец союза через", "troops": "Войска", + "maxtroops": "Максимум войск", "a_troops": "Войска атаки", "gold": "Золото", "ports": "Порты", @@ -575,12 +688,14 @@ "warships": "Военные корабли", "health": "Здоровье", "attitude": "Отношение", - "levels": "Уровни" + "levels": "Уровни", + "wilderness_title": "Пустошь", + "irradiated_wilderness_title": "Радиоактивная пустошь" }, "events_display": { "retreating": "отступает", "retaliate": "Напасть в ответ", - "boat": "Лодка", + "boat": "Судно", "alliance_request_status": "{name} {status} ваш запрос", "alliance_accepted": "принял", "alliance_rejected": "отклонил", @@ -600,8 +715,21 @@ "alliance_renewed": "Ваш союз с {name} был продлён", "wants_to_renew_alliance": "{name} хочет продлить ваш союз", "ignore": "Игнорировать", - "unit_voluntarily_deleted": "Объект добровольно удалён", - "betrayal_debuff_ends": "Осталось {time} сек до окончания наказания предателя" + "unit_voluntarily_deleted": "Сооружение добровольно удалено", + "betrayal_debuff_ends": "Осталось {time} сек до окончания наказания предателя", + "attack_cancelled_retreat": "Атака отменена, {troops} солдат погибло во время отступления", + "received_gold_from_captured_ship": "Получено {gold} золота с корабля, захваченного у {name}", + "received_gold_from_trade": "Получено {gold} золота от торговли с {name}", + "missile_intercepted": "{unit} перехватывает ракету", + "mirv_warheads_intercepted": "{count, plural, one {Перехвачено {count} боеголовку РГЧ ИН} few {Перехвачено {count} боеголовки РГЧ ИН} many {Перехвачено {count} боеголовок РГЧ ИН} other {Перехвачено {count} боеголовок РГЧ ИН}}", + "sent_troops_to_player": "Отправлено {troops} войск к {name}", + "received_troops_from_player": "Получено {troops} войск от {name}", + "sent_gold_to_player": "Отправлено {gold} золота для {name}", + "received_gold_from_player": "Получено {gold} золота от {name}", + "unit_captured_by_enemy": "{name} захватывает ваше сооружение «{unit}»", + "captured_enemy_unit": "Захвачено сооружение «{unit}» у {name}", + "unit_destroyed": "Ваше сооружение «{unit}» было уничтожено", + "no_boats_available": "Нет доступных судов, максимум — {max}" }, "unit_info_modal": { "structure_info": "Информация о структуре", @@ -653,7 +781,10 @@ "send_alliance": "Отправить предложение союза", "send_troops": "Отправить войска", "send_gold": "Отправить золото", - "emotes": "Эмодзи" + "emotes": "Эмодзи", + "arc_up": "Верхняя дуга", + "arc_down": "Нижняя дуга", + "flip_rocket_trajectory": "Отразить траекторию ракеты" }, "send_troops_modal": { "title_with_name": "Отправить войска игроку {name}", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "Выберите стартовое местоположение", - "random_spawn": "Случайное появление включено. Выбираем стартовое местоположение за вас..." + "random_spawn": "Случайное появление включено. Выбираем стартовое местоположение за вас...", + "singleplayer_game_paused": "Игра приостановлена", + "multiplayer_game_paused": "Игра приостановлена владельцем лобби" }, "territory_patterns": { "title": "Скины", "colors": "Цвета", "purchase": "Купить", "show_only_owned": "Мои скины", + "all_owned": "Все узоры куплены! Возвращайтесь позже за новыми товарами.", + "not_logged_in": "Вы не авторизованы", "blocked": { "login": "Вы должны войти, чтобы получить доступ к этому скину.", "purchase": "Купите этот скин, чтобы разблокировать его." }, "pattern": { "default": "По умолчанию" - } + }, + "select_skin": "Выберете узор", + "selected": "выбрано" }, "flag_input": { "title": "Выберите флаг", @@ -732,8 +869,8 @@ "contact_admin": "Если вы считаете, что видите это сообщение по ошибке, пожалуйста, свяжитесь с администратором сайта." }, "radial_menu": { - "delete_unit_title": "Удалить объект", - "delete_unit_description": "Нажмите, чтобы удалить ближайший объект" + "delete_unit_title": "Удалить сооружение", + "delete_unit_description": "Нажмите, чтобы удалить ближайшее сооружение" }, "discord_user_header": { "avatar_alt": "Аватар" @@ -743,7 +880,7 @@ "ship_arrivals": "Прибытия кораблей", "nuke_stats": "Статистика бомбардирования", "player_metrics": "Статистика игрока", - "building": "Строительство", + "building": "Сооружение", "ship_type": "Тип корабля", "weapon": "Оружие", "built": "Построено", @@ -762,19 +899,19 @@ "gold": "Золото", "workers": "Рабочие", "war": "Войны", - "trade": "Обмен", + "trade": "Торговля", "steal": "Украдено", "unit": { "city": "Город", "port": "Порт", "defp": "Укрепление", - "saml": "Пусковая установка ЗРК", + "saml": "ПУ ЗРК", "silo": "Ракетная шахта", "wshp": "Военный корабль", "fact": "Фабрика", "trade": "Торговый корабль", "trans": "Транспортный корабль", - "abomb": "Атомная бомба", + "abomb": "Ядерная бомба", "hbomb": "Водородная бомба", "mirv": "РГЧ ИН", "mirvw": "Боеголовка РГЧ ИН" @@ -786,8 +923,9 @@ "mode": "Режим", "mode_ffa": "Каждый против каждого", "mode_team": "Команда", - "view": "Осмотреть", + "replay": "Повтор", "details": "Подробности", + "ranking": "Рейтинг", "started": "Начато", "map": "Карта", "difficulty": "Сложность", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Публичный", "private": "Приватный", - "singleplayer": "Одиночная игра", + "singleplayer": "Соло", "mode": "Режим", "stats_wins": "Победы", "stats_losses": "Поражения", "stats_wlr": "Соотношение побед:поражений", "stats_games_played": "Игр сыграно", "mode_ffa": "Все против всех", - "mode_team": "Команда" + "mode_team": "Команда", + "no_stats": "Нет данных для этой выборки." + }, + "matchmaking_button": { + "play_ranked": "Рейтинговый подбор 1v1", + "description": "(АЛЬФА)", + "login_required": "Войдите, чтобы играть в рейтинговом режиме!", + "must_login": "Вы должны войти в систему, чтобы играть в рейтинговом режиме." } } diff --git a/resources/lang/tr.json b/resources/lang/tr.json index 0afa36091..940e12098 100644 --- a/resources/lang/tr.json +++ b/resources/lang/tr.json @@ -6,27 +6,54 @@ "lang_code": "tr" }, "common": { - "close": "Kapat" + "close": "Kapat", + "back": "Geri", + "available": "Mevcut", + "preset_max": "Maksimum", + "summary_send": "Gönder", + "summary_keep": "Sakla", + "cancel": "İptal Et", + "send": "Gönder", + "cap_label": "Limit", + "cap_tooltip": "Alıcının kalan kapasitesi", + "target_dead": "Hedef saf dışı kaldı", + "target_dead_note": "Saf dışı kalmış bir oyuncuya kaynak gönderemezsin.", + "none": "Hiçbiri", + "copied": "Kopyalandı!", + "click_to_copy": "Kopyalamak için tıkla" }, "main": { "title": "OpenFront (ALFA)", - "join_discord": "Discord'a katılın!", + "join_discord": "Discord", "login_discord": "Discord'la giriş yap", + "sign_in": "Oturum Aç", + "discord_avatar_alt": "Discord profil avatarı", + "user_avatar_alt": "{username}'in avatarı", "checking_login": "Giriş kontrol ediliyor...", "logged_in": "Giriş yapıldı!", "log_out": "Çıkış yap", - "create_lobby": "Lobi Oluştur", - "join_lobby": "Lobiye Katıl", - "single_player": "Tek Oyunculu", + "create": "Lobi Oluştur", + "join": "Lobiye Katıl", + "solo": "Tekli", "instructions": "Rehber", - "how_to_play": "Nasıl Oynanır", - "advertise": "Reklam Ver", + "game_info": "Oyun bilgisi", "wiki": "Wiki", "privacy_policy": "Gizlilik Politikası", - "terms_of_service": "Hizmet Şartları" + "terms_of_service": "Hizmet Şartları", + "copyright": "© OpenFront™ ve Katkıda Bulunanlar", + "reddit": "Reddit", + "play": "Oyna", + "news": "Haberler", + "store": "Mağaza", + "settings": "Seçenekler", + "keys": "Tuşlar", + "stats": "İstatistikler", + "account": "Hesap", + "help": "Yardım", + "menu": "Menü", + "pick_pattern": "Desen seç!" }, "news": { - "see_all_releases": "Tüm sürümleri gör", "github_link": "GitHub'da", "title": "Sürüm Notları" }, @@ -57,7 +84,7 @@ "ui_events_desc": "Olay paneli en son olayları, istekleri ve Hızlı Sohbet mesajlarını görüntüler. Bazı örnekler şunlardır:", "ui_events_alliance": "İttifak - İttifak istekleri kabul edilebilir veya reddedilebilir. Müttefikler kaynakları ve askerleri paylaşabilir, ancak birbirlerine saldıramazlar. Odaklan'a tıklamak görünümü isteği gönderen oyuncuya taşır.", "ui_events_attack": "Saldırılar - Gelen saldırılar ve giden saldırılarınız gösterilir. Saldırı, nükleer veya Tekne (nakliye gemisi) üzerine görünümü ortalamak için mesaja tıklayın. Kırmızı X düğmesine tıklayarak askerleri geri çekebilirsiniz. Bu, saldıran askerlerinizin %25'inin hayatına mal olur. Bir Tekne saldırısını geri alırsanız, tekne başlangıç noktasına döner ve o zamandan beri toprak ele geçirildiyse orada saldırır. Nükleerler fırlatıldıktan sonra geri alınamaz.", - "ui_events_quickchat": "Hızlı Sohbet - Gönderilen ve alınan sohbet mesajlarını burada görebilirsiniz. Bilgi menüsündeki Hızlı Sohbet simgesine tıklayarak bir oyuncuya mesaj gönderin.", + "ui_events_quickchat": "Hızlı Sohbet - Burada gönderilen ve alınan sohbet mesajlarını görebilirsin. Oyuncunun Bilgi menüsündeki Hızlı Sohbet simgesine tıklayarak oyuncuya mesaj gönderebilirsin.", "ui_options": "Seçenekler", "ui_options_desc": "İçerisinde aşağıdaki öğeler bulunabilir:", "ui_playeroverlay": "Oyuncu bilgi katmanı", @@ -73,6 +100,8 @@ "radial_attack": "Saldırı menüsünü aç.", "radial_info": "Bilgi menüsünü aç.", "radial_boat": "Seçilen konuma saldırması için bir Tekne (nakliye gemisi) gönder. Sadece suya erişiminiz varsa kullanılabilir.", + "radial_donate_troops": "Saldırı oranı kaydırma çubuğundaki yüzdeye eşdeğer sayıda askerleri, radyal menüyü açtığınız müttefikinize bağışlayın.", + "radial_donate_gold": "Müttefiklerinize hızlıca altın yollayabilmeniz için altın bağışı kaydırma menüsünü açar.", "radial_close": "Menüyü kapat.", "info_title": "Bilgi menüsü", "info_enemy_desc": "Seçilen oyuncunun adı, altını, askerleri, sizinle ticareti durdurmuş olup olmadığı, size gönderdiği nükleerler ve oyuncunun hain olup olmadığı gibi bilgileri içerir. Ticareti durdurmuş olmak, onlardan altın almayacağınız ve onlara ticaret gemileri aracılığıyla altın göndermeyeceğiniz anlamına gelir. Manuel olarak (oyuncu \"Ticareti durdur\"a tıklarsa, her ikiniz de \"Ticareti başlat\"a tıklayana kadar sürer) veya otomatik olarak (ittifakınıza ihanet ederseniz, tekrar müttefik olana kadar veya 5 dakika sonra kadar sürer). Hain, oyuncunun kendisiyle ittifak halinde olan bir oyuncuya ihanet edip saldırdığında 30 saniye boyunca Evet gösterir. Aşağıdaki simgeler şu etkileşimleri temsil eder:", @@ -104,7 +133,7 @@ "build_silo": "Füze Silosu", "build_silo_desc": "Füze fırlatmaya izin verir.", "build_sam": "SAM Fırlatıcı", - "build_sam_desc": "100 piksel menzili içindeki düşman füzelerini engelleyebilir. Atom Bombası için %100, Hidrojen Bombası için %80 ve bireysel MIRV Savaş Başlıkları için %50 isabet şansı ile. SAM'ın 7.5 saniye bekleme süresi vardır.", + "build_sam_desc": "100 piksele kadar menzildeki düşman füzelerini önler. SAM 7,5 saniye bekleme süresine sahiptir.", "build_atom": "Atom Bombası", "build_atom_desc": "Bölgeyi, binaları, gemileri ve tekneleri yok eden küçük patlayıcı bomba. En yakın Füze Silosundan doğar ve ilk inşa etmek için tıkladığınız alana düşer.", "build_hydrogen": "Hidrojen Bombası", @@ -119,20 +148,27 @@ "icon_embargo": "Üstü çizili Dolar - Ambargo. Bu oyuncu sizinle ticareti otomatik veya manuel olarak durdurmuş.", "icon_request": "Zarf - İttifak isteği. Bu oyuncu size ittifak isteği göndermiş.", "info_enemy_panel": "Düşman bilgi paneli", - "exit_confirmation": "Oyundan çıkmak istediğine emin misin?" + "exit_confirmation": "Oyundan çıkmak istediğine emin misin?", + "bomb_direction": "Atom / Hidrojen bombası yay yönü" }, "single_modal": { - "title": "Tek Oyunculu", + "title": "Tekli", + "random_spawn": "Rastgele doğma", "allow_alliances": "İttifaklara izin ver", + "toggle_achievements": "Başarımları aç/kapat", + "sign_in_for_achievements": "Başarımlar için oturum aç", "options_title": "Seçenekler", "bots": "Botlar", "bots_disabled": "Devre Dışı", + "nations": "Ülkeler: ", "disable_nations": "Ulusları Devre Dışı Bırak", "instant_build": "Anında İnşa", "infinite_gold": "Sınırsız Altın", - "donate_gold": "Altın bağışla", "infinite_troops": "Sınırsız Asker", - "donate_troops": "Asker bağışla", + "compact_map": "Sıkıştırılmış Harita", + "max_timer": "Oyun süresi (dakika)", + "max_timer_placeholder": "Dakika", + "max_timer_invalid": "Lütfen geçerli bir maksimum zamanlayıcı değeri girin (1-120 dakika)", "disable_nukes": "Nükleerleri Devre Dışı Bırak", "enables_title": "Ayarları Etkinleştir", "start": "Oyunu Başlat" @@ -144,9 +180,62 @@ }, "account_modal": { "title": "Hesap", - "logged_in_as": "{email} olarak oturum açıldı", - "logged_in_with_discord": "Discord'la giriş yapıldı", - "recovery_email_sent": "Kurtarma e-postası {email}'a gönderildi" + "connected_as": "Şu olarak bağlandı", + "stats_overview": "İstatistiklere Genel Bakış", + "link_discord": "Discord Hesabı Bağla", + "log_out": "Çıkış Yap", + "sign_in_desc": "İstatistiklerini ve ilerlemeni kaydetmek için oturum aç", + "or": "YA DA", + "email_placeholder": "E-posta adresini gir", + "get_magic_link": "Sihirli Linkini Al", + "linked_account": "{account_name} olarak giriş yapıldı", + "fetching_account": "Hesap bilgisi alınıyor...", + "recovery_email_sent": "Kurtarma e-postası {email}'a gönderildi", + "not_found": "Bulunamadı", + "clear_session": "Oturumu Temizle", + "failed_to_send_recovery_email": "Kurtarma e-postası gönderimi başarısız", + "enter_email_address": "Lütfen bir e-posta adresi giriniz" + }, + "stats_modal": { + "title": "İstatistikler", + "clan_stats": "Klan İstatistikleri", + "loading": "Yükleniyor...", + "error": "Klan istatistikleri yüklenirken hata", + "no_stats": "Klan istatistikleri mevcut değil", + "no_data_yet": "Henüz Veri Yok", + "clan": "Klan", + "games": "Oyunlar", + "win_score": "Zafer Skoru", + "win_score_tooltip": "Klan katılımı ve maç zorluğuna göre ağırlıklı kazançlar", + "loss_score": "Yenilgi Skoru", + "loss_score_tooltip": "Klan katılımı ve maç zorluğuna göre ağırlıklı kayıplar", + "win_loss_ratio": "Zafer/Yenilgi", + "ratio": "Oran", + "rank": "Sıra", + "try_again": "Tekrar Dene" + }, + "game_info_modal": { + "title": "Oyun bilgisi", + "players": "Oyuncular", + "atoms": "Atomlar", + "hydros": "Hidrojenler", + "mirv": "MIRV", + "bombs": "Bombalar", + "total_gold": "Toplam", + "all_gold": "Tüm altın", + "trade": "Ticaret", + "conquest_gold": "Fethedilen oyuncu altını", + "stolen_gold": "Savaş gemileriyle çalınan", + "num_of_conquests": "Fethedilen oyuncu sayısı", + "duration": "Süre", + "survival_time": "Hayatta kalma süresi", + "war": "Savaş", + "economy": "Ekonomi", + "conquests": "Fetihler", + "pirate": "Korsan", + "conquered": "Fethedildi", + "loading_game_info": "Oyun verileri yükleniyor", + "no_winner": "Bu oyun kazanan olmadan bitti (ya da bir Ülke kazanmadan)" }, "map": { "map": "Harita", @@ -161,6 +250,7 @@ "asia": "Asya", "mars": "Mars", "southamerica": "Güney Amerika", + "britanniaclassic": "Britanya (Klasik)", "britannia": "Britanya", "gatewaytotheatlantic": "Atlantik'e Açılan Kapı", "australia": "Avustralya", @@ -177,16 +267,37 @@ "halkidiki": "Halkidiki", "straitofgibraltar": "Cebelitarık Boğazı", "italia": "İtalya", + "japan": "Japonya", "yenisei": "Yenisey", - "pluto": "Plüto" + "pluto": "Plüto", + "montreal": "Montreal", + "newyorkcity": "New York Şehri", + "achiran": "Achiran", + "baikalnukewars": "Baykal (Nükleer Savaşlar)", + "fourislands": "Dört Adalar", + "gulfofstlawrence": "St. Lawrence Körfezi", + "lisbon": "Lizbon", + "svalmel": "Svalmel", + "manicouagan": "Manicouagan", + "lemnos": "Lemnos", + "sierpinski": "Sierpinski", + "twolakes": "İki Nehir", + "straitofhormuz": "Hürmüz Boğazı", + "surrounded": "Etrafı Çevrili", + "didier": "Didier", + "didierfrance": "Didier (Fransa)", + "amazonriver": "Amazon Nehri" }, "map_categories": { "continental": "Kıtasal", "regional": "Bölgesel", - "fantasy": "Diğer" + "fantasy": "Diğer", + "special": "Özel", + "arcade": "Arcade" }, "map_component": { - "loading": "Yükleniyor..." + "loading": "Yükleniyor...", + "error": "Hata" }, "private_lobby": { "title": "Özel Lobiye Katıl", @@ -196,43 +307,79 @@ "join_lobby": "Lobiye katıl", "checking": "Lobi kontrol ediliyor...", "not_found": "Lobi bulunamadı. Lütfen ID'yi kontrol edip tekrar deneyin.", - "error": "Bir hata oluştu. Lütfen tekrar deneyin.", - "joined_waiting": "Başarıyla katıldınız! Oyunun başlaması bekleniyor..." + "error": "Bir hata oluştu. Lütfen tekrar deneyin ya da destek ile iletişime geçin.", + "joined_waiting": "Lobiye katıldınız! Oda sahibinin başlatması bekleniyor...", + "version_mismatch": "Bu lobi oyunun başka bir sürümü ile oluşturuldu. Katılamazsın.", + "disabled_units": "Devre Dışı Bırakılmış Birimler" }, "public_lobby": { "join": "Sıradaki Oyuna Katıl", "waiting": "oyuncu bekliyor", - "teams_Duos": "İkili (2'li takımlar)", - "teams_Trios": "Üçlü (3'lü takımlar)", - "teams_Quads": "Dörtlü (4'lü takımlar)", - "teams": "{num} takımlar" + "teams_Duos": "{team_count} adet 2 kişilik takımlar (İkili)", + "teams_Trios": "{team_count} adet 3 kişilik takımlar (Üçlü)", + "teams_Quads": "{team_count} adet 4 kişilik takımlar (Dörtlü)", + "waiting_for_players": "Oyuncular bekleniyor", + "starting_game": "Oyun başlatılıyor…", + "teams_hvn": "İnsanlar vs Ülkeler", + "teams_hvn_detailed": "{num} İnsan vs {num} Ülke", + "teams": "{num} takımlar", + "players_per_team": "{num}", + "started": "Başladı" + }, + "matchmaking_modal": { + "title": "1v1 Aşamalı Eşleştirme (ALFA)", + "connecting": "Maç bulma sunucusuna bağlanılıyor...", + "searching": "Oyun aranıyor...", + "waiting_for_game": "Oyunun başlaması bekleniyor...", + "elo": "Senin ELO'n: {elo}" }, "username": { "enter_username": "Kullanıcı adınızı girin", "not_string": "Kullanıcı adı bir metin olmalıdır.", "too_short": "Kullanıcı adı en az {min} karakter uzunluğunda olmalıdır.", "too_long": "Kullanıcı adı {max} karakteri geçmemelidir.", - "invalid_chars": "Kullanıcı adı yalnızca harf, rakam, boşluk, alt çizgi ve [köşeli parantez] içerebilir." + "invalid_chars": "Kullanıcı adı sadece harfler, sayılar, boşluklar ve alt çizgi içerebilir.", + "tag": "ETİKET", + "tag_too_short": "Klan etiketi 2-5 harf arasında olmalı.", + "tag_invalid_chars": "Klan etiketi sadece harf ve sayı içerebilir." }, "host_modal": { - "title": "Özel Lobi", + "title": "Özel Lobi Oluştur", + "label": "Özel", "mode": "Mod", "team_count": "Takım Sayısı", + "team_type": "Takım Türü", "options_title": "Seçenekler", "bots": "Botları:", "bots_disabled": "Devre Dışı", + "player_immunity_duration": "PVP bağışıklık süresi (dakika)", + "nations": "Ülkeler: ", "disable_nations": "Ulusları Devre Dışı Bırak", + "max_timer": "Oyun süresi (dakika)", + "mins_placeholder": "Dakika", "instant_build": "Anında İnşa", "infinite_gold": "Sınırsız Altın", "donate_gold": "Altın bağışla", "infinite_troops": "Sınırsız Asker", "donate_troops": "Asker bağışla", + "compact_map": "Sıkıştırılmış Harita", "enables_title": "Ayarları Etkinleştir", "player": "Oyuncu", "players": "Oyuncular", + "nation_players": "Ülkeler", + "nation_player": "Millet", "waiting": "Oyuncular bekleniyor...", + "random_spawn": "Rastgele doğma", "start": "Oyunu Başlat", - "host_badge": "Host" + "host_badge": "Host", + "assigned_teams": "Atanmış Takımlar", + "empty_teams": "Boş Takımlar", + "empty_team": "Boş", + "remove_player": "{username}'i Kaldır", + "teams_Duos": "İkili (2 kişilik takımlar)", + "teams_Trios": "Üçlü (3 kişilik takımlar)", + "teams_Quads": "Dörtlü (4 kişilik takımlar)", + "teams_Humans Vs Nations": "İnsanlar vs Ülkeler" }, "team_colors": { "red": "Kırmızı", @@ -246,19 +393,24 @@ }, "game_starting_modal": { "title": "Oyun Başlıyor...", - "desc": "Oyun başlamak üzere hazırlanıyor. Lütfen bekleyin." + "credits": "Atıflar", + "code_license": "Kod AGPL-3.0 altında lisanslanmıştır (garanti yok)" }, "difficulty": { - "difficulty": "Zorluk", - "Easy": "Rahat", - "Medium": "Dengeli", - "Hard": "Yoğun", - "Impossible": "İmkansız" + "difficulty": "Ülke Zorluğu", + "easy": "Kolay", + "medium": "Orta", + "hard": "Zor", + "impossible": "İmkansız" }, "game_mode": { "ffa": "Herkes Tek", "teams": "Takımlar" }, + "public_game_modifier": { + "random_spawn": "Rastgele Doğma", + "compact_map": "Sıkıştırılmış Harita" + }, "select_lang": { "title": "Dil seç" }, @@ -275,52 +427,69 @@ "factory": "Fabrika" }, "user_setting": { - "title": "Kullanıcı Ayarları", + "title": "Seçenekler", "tab_basic": "Temel Ayarlar", "tab_keybinds": "Kısayollar", "dark_mode_label": "Karanlık Tema", "dark_mode_desc": "Sitenin görünümünü açık ve koyu tema arasında değiştir", - "dark_mode_enabled": "Karanlık tema etkinleştirildi", - "light_mode_enabled": "Açık tema etkinleştirildi", "emojis_label": "Emojiler", - "emojis_visible": "Emojiler görünüyor", - "emojis_hidden": "Emojiler gizleniyor", "emojis_desc": "Emojilerin oyunda gösterilip gösterilmeyeceğini değiştir", "alert_frame_label": "Uyarı Çerçevesi", - "alert_frame_desc": "Uyarı çerçevesini açın/kapatın. Etkinleştirildiğinde, ihanete uğradığınızda çerçeve gözükür.", + "alert_frame_desc": "Uyarı çerçevesini etkinleştir. Etkinleştirildiğinde, ihanete uğradığınızda veya kara saldırısına uğradığınızda çerçeve gözükür.", "special_effects_label": "Özel efektler", "special_effects_desc": "Özel efektleri aç/kapat. Performansı artırmak için devre dışı bırakın", - "special_effects_enabled": "Özel efektler açık", - "special_effects_disabled": "Özel efektler kapalı", "structure_sprites_label": "Yapı Simgeleri", "structure_sprites_desc": "Yapı simgelerini aç/kapat", - "structure_sprites_enabled": "Yapı Simgeleri etkinleştirildi", - "structure_sprites_disabled": "Yapı Simgeleri devre dışı bırakıldı", + "cursor_cost_label_label": "İmleç Yapım Maliyeti", + "cursor_cost_label_desc": "Oluşturma imleci simgesinin altında bir maliyet hapı göster", "anonymous_names_label": "Adları Gizle", "anonymous_names_desc": "Gerçek oyuncu isimlerini ekranında rastgele isimlerle gizle.", - "anonymous_names_enabled": "Adları gizleme açık", "lobby_id_visibility_label": "Gizli Lobi Kimlikleri", "lobby_id_visibility_desc": "Özel lobi oluştururken lobi kimliğini gizle", - "real_names_shown": "Gerçek adlar görünüyor", + "toggle_visibility": "Görünürlüğü Aç/Kapat", "left_click_label": "Menüyü Açmak için Sol Tık", "left_click_desc": "AÇIK olduğunda, sol tıklama menüyü açar ve kılıç düğmesi saldırır. KAPALI olduğunda, sol tıklama doğrudan saldırır.", "left_click_menu": "Sol Tık Menüsü", - "left_click_opens_menu": "Sol tık menüyü açar", - "right_click_opens_menu": "Sağ tık menüyü açar", "attack_ratio_label": "⚔️ Saldırı Oranı", "attack_ratio_desc": "Bir saldırıda birliklerinin yüzde kaçını göndereceksin (%1-100)", - "troop_ratio_desc": "Askerler (savaş için) ve işçiler (altın üretimi için) arasındaki dengeyi ayarlayın (%1–100)", - "territory_patterns_label": "🏳️ Bölge Desenleri", - "territory_patterns_desc": "Oyunda bölge desenlerinin gösterilip gösterilmeyeceğini seç", + "territory_patterns_label": "🏳️Bölge Desenleri", + "territory_patterns_desc": "Bölge desenlerinin oyunda gösterip gösterilmeyeceğini seç", "performance_overlay_label": "Performans Katmanı", "performance_overlay_desc": "Performans katmanını açın veya kapatın. Etkinleştirildiğinde, performans katmanı görüntülenir. Oyun sırasında Shift-D tuşlarına basarak açıp kapatabilirsiniz.", "easter_writing_speed_label": "Yazma Hızı Çarpanı", "easter_writing_speed_desc": "Kod yazıyormuş gibi yapma hızınızı ayarlayın (x1–x100)", "easter_bug_count_label": "Hata Sayısı", "easter_bug_count_desc": "Kabul edebileceğiniz hata sayısı (0–1000, duygusal olarak)", + "press_a_key": "Bir tuşa bas", "view_options": "Görüntü Seçenekleri", "toggle_view": "Görüntüyü Değiştir", "toggle_view_desc": "Alternatif görünüm (arazi/ülkeler)", + "build_controls": "İnşaat Kontrolleri", + "build_city": "Şehir İnşa Et", + "build_city_desc": "İmlecinin altına bir Şehir inşa et.", + "build_factory": "Fabrika İnşa Et", + "build_factory_desc": "İmlecinin altına bir Fabrika inşa et.", + "build_defense_post": "Bir Savunma Karakolu İnşa Et", + "build_defense_post_desc": "İmlecinin altına bir Savunma Karakolu kur.", + "build_port": "Liman İnşa Et", + "build_port_desc": "İmlecinin altına bir Liman inşa et.", + "build_warship": "Savaş Gemisi İnşa Et", + "build_warship_desc": "İmlecinin altına bir Savaş Gemisi inşa et.", + "build_missile_silo": "Füze Silosu İnşa Et", + "build_missile_silo_desc": "İmlecinin altına bir Füze silosu inşa et.", + "build_sam_launcher": "SAM Fırlatıcı İnşa Et", + "build_sam_launcher_desc": "İmlecinin altına bir SAM Fırlatıcı inşa et.", + "build_atom_bomb": "Atom Bombası İnşa Et", + "build_atom_bomb_desc": "İmlecinin altına bir Atom Bombası inşa et.", + "build_hydrogen_bomb": "Hidrojen Bombası İnşa Et", + "build_hydrogen_bomb_desc": "İmlecinin altına bir Hidrojen Bombası inşa et.", + "build_mirv": "MIRV İnşa Et", + "build_mirv_desc": "İmlecinin altına MIRV inşa et.", + "menu_shortcuts": "Menü Kısayolları", + "build_menu_modifier": "Yapı Menüsü Değiştiricisi", + "build_menu_modifier_desc": "Yapı menüsünü açmak için tıklarken bu tuşa basılı tut.", + "emoji_menu_modifier": "Emoji Menüsü Değiştiricisi", + "emoji_menu_modifier_desc": "Emoji menüsünü açmak için tıklarken bu tuşa basılı tut.", "attack_ratio_controls": "Saldırı Oranı Kontrolleri", "attack_ratio_up": "Saldırı Oranını Artır", "attack_ratio_up_desc": "Saldırı oranını %10 artır", @@ -331,6 +500,8 @@ "boat_attack_desc": "İmlecinizin altındaki kareye tekne saldırısı gönder.", "ground_attack": "Kara Saldırısı", "ground_attack_desc": "İmlecinin altındaki kareye kara saldırısı gönderir.", + "swap_direction": "Roket Yönünü Değiştir", + "swap_direction_desc": "Roket fırlatma yönünü aç/kapat (yukarı/aşağı).", "zoom_controls": "Yakınlaştırma Kontrolleri", "zoom_out": "Uzaklaştır", "zoom_out_desc": "Haritayı uzaklaştır", @@ -352,10 +523,11 @@ "on": "Açık", "off": "Kapalı", "toggle_terrain": "Araziyi Göster", - "terrain_enabled": "Arazi görünümü etkinleştirildi", - "terrain_disabled": "Arazi görünümü kapatıldı", "exit_game_label": "Oyundan Çık", - "exit_game_info": "Ana menüye dön" + "exit_game_info": "Ana menüye dön", + "background_music_volume": "Arkaplan Müziği Sesi", + "sound_effects_volume": "Ses Efektleri Sesi", + "keybind_conflict_error": "{key} tuşu zaten başka bir eyleme atanmış." }, "chat": { "title": "Hızlı Sohbet", @@ -462,15 +634,24 @@ }, "win_modal": { "support_openfront": "OpenFront'u Destekle!", - "territory_pattern": "OpenFront'u desteklemek için bölge deseni satın al!", + "territory_pattern": "Bir toprak kaplaması alarak reklamlardan kurtul!", "died": "Öldün", "your_team": "Takımınız kazandı!", "other_team": "{team} takımı kazandı!", "you_won": "Kazandın!", "other_won": "{player} kazandı!", + "nation_won": "{nation} ülkesi kazandı!", "exit": "Oyundan Çık", "keep": "Oynamaya Devam Et", - "wishlist": "Steam'de İstek Listesine Ekle!" + "spectate": "İzle", + "wishlist": "Steam'de İstek Listesine Ekle!", + "ofm_winter": "OpenFront Masters Kış Turnuvası!", + "ofm_winter_description": "Rekabetçi turnuvaya katıl ve en iyi oyunculara karşı mücadele et", + "join_tournament": "Turnuvaya Katıl", + "join_discord": "Discord Topluluğumuza Katıl!", + "discord_description": "Oyuncularla bağlantı kurun, yeni özellikleri keşfedin ve ödüller kazanın!", + "join_server": "Sunucuya Katıl", + "youtube_tutorial": "Yardım lazım mı?" }, "leaderboard": { "title": "Lider Tablosu", @@ -480,7 +661,7 @@ "team": "Takım", "owned": "Sahip Olunan", "gold": "Altın", - "troops": "Askerler", + "maxtroops": "Maks birlikler", "launchers": "Fırlatıcılar", "sams": "SAM'ler", "warships": "Savaş Gemileri", @@ -494,7 +675,9 @@ "nation": "Ulus", "player": "Oyuncu", "team": "Takım", - "d_troops": "Savunma birliği", + "alliance_timeout": "İttifak şu sürede bitiyor", + "troops": "Birlikler", + "maxtroops": "Maks birlikler", "a_troops": "Saldırı birliği", "gold": "Altın", "ports": "Limanlar", @@ -505,10 +688,13 @@ "warships": "Savaş gemileri", "health": "Sağlık", "attitude": "Tutum", - "levels": "Seviyeler" + "levels": "Seviyeler", + "wilderness_title": "Vahşi Doğa", + "irradiated_wilderness_title": "Radyoaktif Vahşi Doğa" }, "events_display": { "retreating": "geri çekiliyor", + "retaliate": "Karşılık Ver", "boat": "Tekne", "alliance_request_status": "{name} {status} ittifak isteğiniz", "alliance_accepted": "kabul edildi", @@ -527,8 +713,23 @@ "accept_alliance": "Kabul et", "reject_alliance": "Reddet", "alliance_renewed": "{name} ile ittifakın yenilendi", + "wants_to_renew_alliance": "{name} ittifakı yenilemek istiyor", "ignore": "Yoksay", - "unit_voluntarily_deleted": "Birim gönüllü olarak silindi" + "unit_voluntarily_deleted": "Birim gönüllü olarak silindi", + "betrayal_debuff_ends": "İhanet zayıflatmasının bitmesine {time} saniye kaldı", + "attack_cancelled_retreat": "Saldırı iptal edildi, {troops} asker geri çekilme sırasında öldürüldü", + "received_gold_from_captured_ship": "{name}'den ele geçirilen gemiden {gold} altın alındı", + "received_gold_from_trade": "{name} ile yapılan ticaretten {gold} altın alındı", + "missile_intercepted": "{unit} füzesi önlendi", + "mirv_warheads_intercepted": "{count, plural,one {{count} MIRV başlığı önlendi} other {{count} MIRV başlığı önlendi}}", + "sent_troops_to_player": "{name}'e {troops} birlik gönderildi", + "received_troops_from_player": "{name}'den {troops} birlik alındı", + "sent_gold_to_player": "{name}'e {gold} altın gönderildi", + "received_gold_from_player": "{name}'den {gold} altın alındı", + "unit_captured_by_enemy": "{unit} {name} tarafından ele geçirildi", + "captured_enemy_unit": "{name}'den {unit} ele geçirildi", + "unit_destroyed": "{unit} yok edildi", + "no_boats_available": "Bot mevcut değil, maks {max}" }, "unit_info_modal": { "structure_info": "Bina Bilgisi", @@ -539,6 +740,11 @@ "upgrade": "Yükselt", "level": "Seviye" }, + "player_type": { + "player": "Oyuncu", + "nation": "Ulus", + "bot": "Bot" + }, "relation": { "hostile": "Düşman", "distrustful": "Güvensiz", @@ -554,23 +760,50 @@ "player_panel": { "gold": "Altın", "troops": "Birlik", - "betrayals": "İhanet sayısı", + "betrayals": "İhanetler", "traitor": "Hain", + "trading": "Ticaret", + "active": "Aktif", + "stopped": "Durduruldu", "alliance_time_remaining": "İttifak Şu Süre Sonra Bitecek", "embargo": "Sizinle ticareti durdurdu", "nuke": "Onlar tarafından size gönderilen nükleerler", - "start_trade": "Ticareti başlat", - "stop_trade": "Ticareti durdur", - "yes": "Evet", - "no": "Hayır", - "none": "Hiçbiri", + "start_trade": "Ticarete Başla", + "stop_trade": "Ticareti Durdur", + "stop_trade_all": "Herkesle Ticareti Durdur", + "start_trade_all": "Herkesle Ticareti Başlat", "alliances": "İttifaklar", - "flag": "Bayrak" + "flag": "Bayrak", + "chat": "Sohbet", + "target": "Hedef", + "break_alliance": "İttifakı Boz", + "alliance": "İttifak", + "send_alliance": "İttifaklık İsteği Gönder", + "send_troops": "Birlik Gönder", + "send_gold": "Altın Gönder", + "emotes": "Emojiler", + "arc_up": "Yukarı yay", + "arc_down": "Aşağı yay", + "flip_rocket_trajectory": "Roket yörüngesini çevir" + }, + "send_troops_modal": { + "title_with_name": "{name}'e Birlik Gönder", + "available_tooltip": "Şu anda mevcut olan birliklerin", + "min_keep": "Minimum Elinde Tutma", + "slider_tooltip": "%{{percent}} •{{amount}}", + "aria_slider": "Birlik çubuğu", + "capacity_note": "Alıcı ancak {{amount}} kadar alabilir." + }, + "send_gold_modal": { + "title_with_name": "{name}'e Altın Gönder", + "available_tooltip": "Mevcut altının", + "aria_slider": "Miktar çubuğu", + "slider_tooltip": "%{{percent}} • {{amount}}" }, "replay_panel": { "replay_speed": "Tekrar oynatma hızı", "game_speed": "Oyun hızı", - "fastest_game_speed": "maks" + "fastest_game_speed": "Maks" }, "error_modal": { "crashed": "Oyun çöktü!", @@ -579,53 +812,47 @@ "copy_clipboard": "Panoya kopyala", "copied": "Kopyalandı!", "failed_copy": "Kopyalama başarısız", + "spawn_failed": { + "title": "Doğma başarısız", + "description": "Otomatik doğma seçimi başarısız. Bu oyunu oynayamazsın." + }, "desync_notice": "Diğer oyuncularla senkronizasyonunuz bozuldu. Gördükleriniz diğer oyunculardan farklı olabilir." }, + "performance_overlay": { + "reset": "Sıfırla", + "copy_json_title": "Mevcut performans metriklerini JSON olarak kopyala", + "copy_clipboard": "JSON'ı kopyala", + "copied": "Kopyalandı!", + "failed_copy": "Kopyalama başarısız", + "fps": "FPS:", + "avg_60s": "Ortalama (60s):", + "frame": "Kare:", + "tick_exec": "Tik Yürütme:", + "tick_delay": "Tik Gecikmesi:", + "layers_header": "Katmanlar (ort / maks, toplam vakte göre sıralanmış):" + }, "heads_up_message": { - "choose_spawn": "Başlangıç konumu seçin" + "choose_spawn": "Başlangıç konumu seçin", + "random_spawn": "Rastgele doğma aktif. Başlangıç noktası senin için seçiliyor...", + "singleplayer_game_paused": "Oyun durduruldu", + "multiplayer_game_paused": "Oyun Lobi Sahibi tarafından durduruldu" }, "territory_patterns": { - "title": "Bölge Deseni Seç", + "title": "Kaplamalar", + "colors": "Renkler", "purchase": "Satın al", + "show_only_owned": "Kaplamalarım", + "all_owned": "Bütün kaplamalara sahipsin! Yeni eşyalar için sonra tekrar kontrol et.", + "not_logged_in": "Giriş yapılmadı", "blocked": { - "login": "Bu desene erişmek için oturum açmanız gerekir.", - "purchase": "Bu deseni satın alarak kilidini açın." + "login": "Bu kaplamaya erişmek için giriş yapmış olman lazım.", + "purchase": "Bu kaplamayı açmak için satın al." }, "pattern": { - "default": "Varsayılan", - "custom": "Özel", - "stripes_v": "Dikey", - "stripes_h": "Yatay", - "horizontal_stripes": "Yatay (Alt)", - "vertical_bars": "Dikey (Alt)", - "checkerboard": "Dama tahtası", - "choco": "Çiko", - "diagonal": "Çapraz", - "cross": "Çarpı", - "mini_cross": "Mini Çarpı", - "sword": "Kılıç", - "sparse_dots": "Seyrek Noktalar", - "evan": "Evan", - "diagonal_stripe": "Çapraz Çizgi", - "mountain_ridge": "Dağ Sırtı", - "scattered_dots": "Dağınık Noktalar", - "circuit_board": "Devre Kartı", - "shells": "Kabuklar", - "-w-": ".w.", - "white_rabbit": "Beyaz Tavşan", - "goat": "Keçi", - "cats": "Kediler", - "cursor": "İmleç", - "hand": "El", - "radiation": "Radyasyon", - "openfront_qr": "OpenFront.io QR Kodu", - "openfront": "OpenFront", - "t_rex": "T-Rex", - "embelem": "Amblem", - "contributor": "Katkıda Bulunan", - "grogu_head": "Grogu Başı", - "grogu": "Grogu" - } + "default": "Varsayılan" + }, + "select_skin": "Kaplama Seç", + "selected": "seçildi" }, "flag_input": { "title": "Bayrak Seç", @@ -644,5 +871,83 @@ "radial_menu": { "delete_unit_title": "Birimi Sil", "delete_unit_description": "En yakın birimi silmek için tıklayın" + }, + "discord_user_header": { + "avatar_alt": "Profil Resmi" + }, + "player_stats_table": { + "building_stats": "Yapı İstatistikleri", + "ship_arrivals": "Gelen Gemiler", + "nuke_stats": "Nükleer İstatistikleri", + "player_metrics": "Oyuncu İstatistikleri", + "building": "Yapı", + "ship_type": "Gemi Türü", + "weapon": "Silah", + "built": "İnşa Edildi", + "destroyed": "Yok Edildi", + "captured": "Ele Geçirildi", + "lost": "Kaybedildi", + "hits": "Vuruşlar", + "launched": "Fırlatıldı", + "landed": "İniş Yaptı", + "sent": "Gönderildi", + "arrived": "Ulaştı", + "attack": "Saldır", + "received": "Alındı", + "cancelled": "İptal Edildi", + "count": "Miktar", + "gold": "Altın", + "workers": "İşçiler", + "war": "Savaş", + "trade": "Ticaret", + "steal": "Çal", + "unit": { + "city": "Şehir", + "port": "Liman", + "defp": "Savunma Karakolu", + "saml": "SAM Fırlatıcı", + "silo": "Füze Silosu", + "wshp": "Savaş Gemisi", + "fact": "Fabrika", + "trade": "Ticaret Gemisi", + "trans": "Nakliye Gemisi", + "abomb": "Atom Bombası", + "hbomb": "Hidrojen Bombası", + "mirv": "MIRV", + "mirvw": "MIRV Savaş Başlığı" + } + }, + "game_list": { + "recent_games": "Son Oyunlar", + "game_id": "Oyun ID'si", + "mode": "Mod", + "mode_ffa": "Herkes Tek", + "mode_team": "Takım", + "replay": "Tekrar", + "details": "Detaylar", + "ranking": "Sıralama", + "started": "Başladı", + "map": "Harita", + "difficulty": "Zorluk", + "type": "Tür" + }, + "player_stats_tree": { + "public": "Herkese Açık", + "private": "Özel", + "singleplayer": "Tekli", + "mode": "Mod", + "stats_wins": "Galibiyetler", + "stats_losses": "Yenilgiler", + "stats_wlr": "Kazanma:Kaybetme Oranı", + "stats_games_played": "Oynanan Oyunlar", + "mode_ffa": "Herkes Tek", + "mode_team": "Takım", + "no_stats": "Bu seçenek için veri kaydedilmedi." + }, + "matchmaking_button": { + "play_ranked": "1v1 Aşamalı Eşleştirme", + "description": "(ALFA)", + "login_required": "Aşamalı oynamak için giriş yap!", + "must_login": "Aşamalı eşleştirme oynamak için giriş yapmanız gerek." } } diff --git a/resources/lang/uk.json b/resources/lang/uk.json index 2c11971d3..80e797bac 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -7,6 +7,7 @@ }, "common": { "close": "Закрити", + "back": "Назад", "available": "Доступно", "preset_max": "Максимум", "summary_send": "Переказ", @@ -17,32 +18,48 @@ "cap_tooltip": "Залишкова місткість отримувача", "target_dead": "Ціль знищено", "target_dead_note": "Неможливо надіслати ресурси полеглому гравцю.", - "none": "Немає" + "none": "Немає", + "copied": "Скопійовано!", + "click_to_copy": "Натисніть, щоб скопіювати" }, "main": { "title": "OpenFront (АЛЬФА)", "join_discord": "Discord", "login_discord": "Увійти з Discord", + "sign_in": "Увійти", + "discord_avatar_alt": "Аватар профілю Discord", + "user_avatar_alt": "Аватар {username}", "checking_login": "Перевірка входу...", - "logged_in": "Вхід виконано!", + "logged_in": "Вхід здійснено!", "log_out": "Вийти", - "create_lobby": "Створити лобі", - "join_lobby": "Приєднатися до лобі", - "single_player": "Гра наодинці", + "create": "Створити лобі", + "join": "Приєднатися до лобі", + "solo": "Соло", "instructions": "Інструкції", + "game_info": "Інформація про гру", "wiki": "Вікі", "privacy_policy": "Політика конфіденційності", "terms_of_service": "Умови користування", - "reddit": "Reddit" + "copyright": "© OpenFront™ і співавтори", + "reddit": "Reddit", + "play": "Грати", + "news": "Новини", + "store": "Крамниця", + "settings": "Налаштування", + "keys": "Клавіші", + "stats": "Статистика", + "account": "Акаунт", + "help": "Допомога", + "menu": "Меню", + "pick_pattern": "Оберіть візерунок!" }, "news": { - "see_all_releases": "Переглянути всі випуски", "github_link": "на GitHub", "title": "Список змін" }, "help_modal": { "hotkeys": "Гарячі клавіші", - "table_key": "Клавіш", + "table_key": "Клавіша", "table_action": "Дія", "action_alt_view": "Альтернативний вигляд (рельєф/країни)", "action_attack_altclick": "Атака (коли лівий клац призначено на відкриття меню)", @@ -60,7 +77,7 @@ "ui_leaderboard_desc": "Показує найкращих гравців гри та їхні імена, % підконтрольних територій, кількість золота та військ. За допомогою кнопки «Показати все» ви можете переглянути всіх гравців у грі. Якщо ви не бажаєте бачити таблицю лідерів, натисніть «Приховати».", "ui_control": "Панель керування", "ui_control_desc": "Панель керування містить наступні елементи:", - "ui_pop": "Населення — Кількість ваших підрозділів, максимальне населення та темп його приросту.", + "ui_pop": "Населення — Кількість ваших підрозділів, ліміт населення та темп його приросту.", "ui_gold": "Золото — Обсяг вашого золота та швидкість, з якою ви отримуєте його.", "ui_attack_ratio": "Коефіцієнт атаки — Кількість військ, що беруть участь в атаці. Ви можете налаштувати коефіцієнт атаки за допомогою повзунка. Якщо наступальних військ більше ніж оборонних, то буде зменшено втрати під час атаки, а якщо менше — буде збільшено шкоду, що буде завдано вашим наступальним військам. Ефективність не збільшується після коефіцієнту 2:1.", "ui_events": "Панель подій", @@ -76,13 +93,15 @@ "option_pause": "Призупинити/Продовжити гру — Доступно лише в режимі гри наодинці.", "option_timer": "Таймер — Час, що минув із початку гри.", "option_exit": "Кнопка виходу.", - "option_settings": "Налаштування — Відкрити меню налаштувань. У ньому можна увімкнути/вимкнути режим альтернативного вигляду, емоджі, темний режим, нінджя (режим прихованих/випадкових імен) та виконання дії при клацанні лівою кнопкою миші.", + "option_settings": "Налаштування — Відкрити меню налаштувань. У ньому можна перемкнути режим альтернативного вигляду, емоджі, темний режим, нінджя (режим прихованих/випадкових імен) та виконання дії при клацанні лівою кнопкою миші.", "radial_title": "Кругове меню", "radial_desc": "Правий клац (або дотик на мобільних пристроях) відкриває кругове меню. Клацніть правою кнопкою миші поза ним, щоб закрити його. У меню ви можете:", "radial_build": "Відкрити меню будівництва.", "radial_attack": "Відкрити меню атаки.", "radial_info": "Відкрити меню інформації.", "radial_boat": "Відправити човен (транспортний корабель) атакувати вибране розташування. Доступно лише якщо ви маєте доступ до води.", + "radial_donate_troops": "Пожертвувати кількість військ, що дорівнює коефіцієнту повзунку атаки тому союзнику, на якому ви відкрили кругове меню.", + "radial_donate_gold": "Відкриває меню повзунка пожертвування золота для швидкого надсилання золота союзникам.", "radial_close": "Закрити меню.", "info_title": "Меню інформації", "info_enemy_desc": "Містить таку інформацію про вибраного гравця, як його імʼя, кількість золота, військ, стан торгувілі з вами, кількість запущених на вас ракет і мітку зрадника. Припинення торгівля означає, що ви не отримуватиме золото від гравця, а він не надсилатиме вам золото торговельними кораблями. Свідомо (якщо гравець натиснув «Припинити торгівлю», що триває, поки ви обидва не натиснете «Розпочати торгівлю») або автоматично (якщо ви зрадили союз, що триває, поки ви знову не станете союзниками або через 5 хвилин). Поле «Зрадник» показує стан «Так» протягом 30 секунд після того, як гравець зрадив й атакував гравця, який перебував у союзні з ним. Значки нижче позначають такі взаємодії:", @@ -102,7 +121,7 @@ "build_icon": "Значок", "build_desc": "Опис", "build_city": "Місто", - "build_city_desc": "Збільшує вашу максимальну кількість населення. Корисно, коли ви не можете розширити територію або населення сягає ліміту.", + "build_city_desc": "Збільшує ваш ліміт населення. Корисно, коли ви не можете розширити територію або населення незабаром досягне ліміту.", "build_factory": "Фабрика", "build_factory_desc": "Автоматично прокладає залізничні колії до найближчих міст, портів та інших фабрик. Також може обʼєднуватися з дружніми сусідніми країнами. Поїзди зʼявляються регулярно і дають сталу кількість золота за кожну будівлю, яку проїжджають на шляху, із бонусом за відвідування будівель сусідніх країн.", "build_defense": "Пункт оборони", @@ -114,7 +133,7 @@ "build_silo": "Ракетна шахта", "build_silo_desc": "Дає можливість запускати ракети.", "build_sam": "ПУ ЗРК", - "build_sam_desc": "Дозволяє перехоплювати ворожі ракети в радіусі 100 пікселів. Має 100% шанс на збиття атомної бомби, 80% — водневої бомби та 50% — окремих боєголовок РГЧ ІН. ЗРК має період перезаряджання в 7,5 секунд.", + "build_sam_desc": "Може перехоплювати ворожі ракети в радіусі 100 пікселів. ЗРК має період перезаряджання в 7,5 секунд.", "build_atom": "Атомна бомба", "build_atom_desc": "Невелика вибухова бомба, яка руйнує територію, будівлі, кораблі та човни. Запускається з найближчої ракетної шахти та вражає область, вибрану клацанням кнопкою миші.", "build_hydrogen": "Воднева бомба", @@ -129,12 +148,15 @@ "icon_embargo": "Закреслений знак долара — Ембарго. Цей гравець припинив торгівлю з вами; автоматично чи свідомо.", "icon_request": "Конверт — Запрошення до союзу. Гравець надіслав вам запит на укладення союзу.", "info_enemy_panel": "Панель інформації про ворога", - "exit_confirmation": "Ви впевнені, що хочете вийти з гри?" + "exit_confirmation": "Ви впевнені, що хочете вийти з гри?", + "bomb_direction": "Траєкторія польоту атомної/водородної бомби" }, "single_modal": { - "title": "Гра наодинці", + "title": "Соло", "random_spawn": "Випадкова поява", "allow_alliances": "Дозволити союзи", + "toggle_achievements": "Перемикання досягнень", + "sign_in_for_achievements": "Увійдіть, щоб отримувати досягнення", "options_title": "Налаштування", "bots": "Боти: ", "bots_disabled": "Відключені", @@ -145,6 +167,8 @@ "infinite_troops": "Необмежені війська", "compact_map": "Компактна мапа", "max_timer": "Тривалість гри (хвилини)", + "max_timer_placeholder": "Хвилини", + "max_timer_invalid": "Будь ласка, введіть дійсне максимальне значення таймера (1–120 хвилин)", "disable_nukes": "Вимкнути бомби", "enables_title": "Дозволи", "start": "Розпочати гру" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "Акаунт", - "logged_in_as": "Ви увійшли як {email}", + "connected_as": "Підʼєднано як", + "stats_overview": "Огляд статистики", + "link_discord": "Повʼязати акаунт Discord", + "log_out": "Вийти", + "sign_in_desc": "Увійдіть, щоб зберегти статистику та прогрес", + "or": "АБО", + "email_placeholder": "Введіть свою електронну пошту", + "get_magic_link": "Отримати чарівне посилання", + "linked_account": "Ви увійшли як {account_name}", "fetching_account": "Отримання інформації про акаунт...", - "logged_in_with_discord": "Ви увійшли через Discord", - "recovery_email_sent": "Лист для відновлення надіслано на {email}" + "recovery_email_sent": "Лист для відновлення надіслано на {email}", + "not_found": "Не знайдено", + "clear_session": "Очистити сесію", + "failed_to_send_recovery_email": "Не вдалося надіслати електронний лист для відновлення", + "enter_email_address": "Будь ласка, введіть адресу електронної пошти" }, "stats_modal": { "title": "Статистика", @@ -167,11 +202,40 @@ "loading": "Завантаження...", "error": "Помилка завантаження статистики кланів", "no_stats": "Статистика кланів недоступна", + "no_data_yet": "Дані поки що відсутні", "clan": "Клан", "games": "Ігри", "win_score": "Рахунок перемог", + "win_score_tooltip": "Зважені перемоги на основі участі клану та складності матчу", "loss_score": "Рахунок поразок", - "win_loss_ratio": "Перемоги/Поразки" + "loss_score_tooltip": "Зважені поразки на основі участі клану та складності матчу", + "win_loss_ratio": "Перемоги/Поразки", + "ratio": "Коефіцієнт", + "rank": "Ранг", + "try_again": "Спробуйте ще раз" + }, + "game_info_modal": { + "title": "Інформація про гру", + "players": "Гравці", + "atoms": "Атомні бомби", + "hydros": "Водневі бомби", + "mirv": "РГЧ ІН", + "bombs": "Бомби", + "total_gold": "Загалом", + "all_gold": "Усе золото", + "trade": "Торгівля", + "conquest_gold": "Загарбане золото гравців", + "stolen_gold": "Викрадено воєнними кораблями", + "num_of_conquests": "Кількість підкорених гравців", + "duration": "Тривалість", + "survival_time": "Час виживання", + "war": "Війна", + "economy": "Економіка", + "conquests": "Завоювання", + "pirate": "Піратство", + "conquered": "Завойовано", + "loading_game_info": "Завантаження статистики ігор", + "no_winner": "Ця гра закінчилася без переможця (або перемогла нація)" }, "map": { "map": "Мапа", @@ -186,6 +250,7 @@ "asia": "Азія", "mars": "Марс", "southamerica": "Південна Америка", + "britanniaclassic": "Британія (класична)", "britannia": "Британія", "gatewaytotheatlantic": "Гібралтарська протока", "australia": "Австралія", @@ -206,22 +271,36 @@ "yenisei": "Єнісей", "pluto": "Плутон", "montreal": "Монреаль", + "newyorkcity": "Нью-Йорк", "achiran": "Акіран", "baikalnukewars": "Байкал (ядерні війни)", "fourislands": "Чотири острови", "gulfofstlawrence": "Затока Св. Лаврентія", - "lisbon": "Лісабон" + "lisbon": "Лісабон", + "svalmel": "Свалмел", + "manicouagan": "Манікуаган", + "lemnos": "Лемнос", + "sierpinski": "Серпінський", + "twolakes": "Два озера", + "straitofhormuz": "Ормузька протока", + "surrounded": "Оточення", + "didier": "Дідьє", + "didierfrance": "Дідьє (Франція)", + "amazonriver": "Річка Амазонка" }, "map_categories": { "continental": "Континентальні", "regional": "Регіональні", - "fantasy": "Інші" + "fantasy": "Інші", + "special": "Особливі", + "arcade": "Аркадні" }, "map_component": { - "loading": "Завантаження..." + "loading": "Завантаження...", + "error": "Помилка" }, "private_lobby": { - "title": "Приєднатися до приватного лобі", + "title": "Приєднання до приватного лобі", "enter_id": "Введіть ID лобі", "player": "Гравець", "players": "Гравці(в)", @@ -229,42 +308,55 @@ "checking": "Перевірка лобі...", "not_found": "Лобі не знайдено. Будь ласка, перевірте дійсність ID і спробуйте знову.", "error": "Сталася помилка. Спробуйте ще раз або зверніться до служби підтримки.", - "joined_waiting": "Ви успішно приєдналися! Очікування початку гри...", - "version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися." + "joined_waiting": "Лобі приєднано! Очікуємо, доки хост почне гру...", + "version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися.", + "disabled_units": "Вимкнені споруди" }, "public_lobby": { "join": "Приєднатися до наступної гри", "waiting": "гравці(в) очікують", - "teams_Duos": "по 2 (дуо)", - "teams_Trios": "по 3 (тріо)", - "teams_Quads": "по 4 (квади)", + "teams_Duos": "{team_count} команд по 2 (дуо)", + "teams_Trios": "{team_count} команд по 3 (тріо)", + "teams_Quads": "{team_count} команд по 4 (квади)", + "waiting_for_players": "Очікування гравців", + "starting_game": "Початок гри…", "teams_hvn": "Люди проти націй", - "teams": "Команд: {num}", - "players_per_team": "по {num}" + "teams_hvn_detailed": "{num} людей проти {num} націй", + "teams": "Команди: {num}", + "players_per_team": "по {num}", + "started": "Почато" }, "matchmaking_modal": { - "title": "Підбір гравців", + "title": "Рейтинговий підбір 1v1 (АЛЬФА)", "connecting": "Приєднання до сервера підбору гравців...", "searching": "Пошук гри...", - "waiting_for_game": "Очікування початку гри..." + "waiting_for_game": "Очікування початку гри...", + "elo": "Ваш ELO: {elo}" }, "username": { "enter_username": "Введіть своє імʼя гравця", "not_string": "Імʼя гравця має бути рядком.", "too_short": "Імʼя гравця повинно містити щонайменше {min} символів.", "too_long": "Довжина імʼя гравця не повинна перевищувати {max} символів.", - "invalid_chars": "Імʼя гравця може містити лише латинські літери, цифри, пробіли, знаки підкреслення та [квадратні дужки]." + "invalid_chars": "Імʼя гравця може містити лише латинські літери, цифри, пробіли та підкреслення.", + "tag": "ТЕГ", + "tag_too_short": "Тег клану має складатися з 2–5 абетко-цифрових символів.", + "tag_invalid_chars": "Тег клану може містити лише латинські літери та цифри." }, "host_modal": { - "title": "Приватне лобі", + "title": "Створення приватного лобі", + "label": "Приватний", "mode": "Режим", "team_count": "Кількість команд", + "team_type": "Тип команди", "options_title": "Налаштування", "bots": "Боти: ", "bots_disabled": "Відключені", + "player_immunity_duration": "Тривалість імунітету в PVP (хвилини)", "nations": "Нації: ", "disable_nations": "Вимкнути нації", "max_timer": "Тривалість гри (хвилини)", + "mins_placeholder": "Хвилини", "instant_build": "Миттєве будівництво", "infinite_gold": "Безмежне золото", "donate_gold": "Пожертвування золота", @@ -283,7 +375,11 @@ "assigned_teams": "Розподілені команди", "empty_teams": "Порожні команди", "empty_team": "Немає", - "remove_player": "Вилучити {username}" + "remove_player": "Вилучити {username}", + "teams_Duos": "Дуо (команди по 2)", + "teams_Trios": "Тріо (команди по 3)", + "teams_Quads": "Квади (команди по 4)", + "teams_Humans Vs Nations": "Люди проти націй" }, "team_colors": { "red": "Червоний", @@ -301,18 +397,22 @@ "code_license": "Код ліцензовано під AGPL-3.0 (без гарантій)" }, "difficulty": { - "difficulty": "Складність", - "Easy": "Розслаблена", - "Medium": "Збалансована", - "Hard": "Напружена", - "Impossible": "Неможлива" + "difficulty": "Складність націй", + "easy": "Легко", + "medium": "Середньо", + "hard": "Важко", + "impossible": "Неможливо" }, "game_mode": { "ffa": "Усі проти всіх", "teams": "Команди" }, + "public_game_modifier": { + "random_spawn": "Випадкова поява", + "compact_map": "Компактна мапа" + }, "select_lang": { - "title": "Виберіть мову" + "title": "Вибір мови" }, "unit_type": { "city": "Місто", @@ -327,51 +427,54 @@ "factory": "Фабрика" }, "user_setting": { - "title": "Користувацькі налаштування", + "title": "Налаштування", "tab_basic": "Основні налаштування", "tab_keybinds": "Призначення клавіш", "dark_mode_label": "Темний режим", "dark_mode_desc": "Перемикання зовнішнього вигляду сайту між світлою та темною темою", "emojis_label": "Емоджі", - "emojis_desc": "Увімкнення/вимкнення видимости емоджі під час гри", + "emojis_desc": "Перемкнути видимість емоджі під час гри", "alert_frame_label": "Рамка тривоги", - "alert_frame_desc": "Увімкнути/вимкнути рамку тривоги. Якщо увімкнено, вона показуватиметься, коли вас зраджують або атакують по суші.", + "alert_frame_desc": "Перемкнути рамку тривоги. Якщо увімкнено, рамка показуватиметься, коли вас зраджують або атакують по суші.", "special_effects_label": "Спецефекти", - "special_effects_desc": "Увімкнути/вимкнути спецефекти. Вимкніть для поліпшення продуктивности", + "special_effects_desc": "Перемкнути спецефекти. Вимкніть для поліпшення продуктивности", "structure_sprites_label": "Спрайти споруд", - "structure_sprites_desc": "Увімкнення/вимкнення спрайтів споруд", + "structure_sprites_desc": "Перемкнути спрайти споруд", + "cursor_cost_label_label": "Вартість будування під указівником", + "cursor_cost_label_desc": "Показувати вартість будівництва під указівником", "anonymous_names_label": "Приховані імена", "anonymous_names_desc": "Приховати справжні імена гравців і замінити їх випадковими.", "lobby_id_visibility_label": "Приховані ID лобі", "lobby_id_visibility_desc": "Приховати ID при створенні приватного лобі", + "toggle_visibility": "Перемикання видимости", "left_click_label": "Відкриття меню лівою кнопкою миші", "left_click_desc": "УВІМКНЕНО — лівий клац відкриває меню, кнопкою з мечем здійснює атаку. ВИМКНЕНО — лівий клац одразу атакує.", "left_click_menu": "Меню на лівий клац миші", "attack_ratio_label": "⚔️ Коефіцієнт атаки", - "attack_ratio_desc": "Який відсоток ваших бере учать в атаці (1–100%)", - "troop_ratio_desc": "Налаштуйте співвідношення між військами (для бою) та працівниками (для видобування золота) (1–100%)", + "attack_ratio_desc": "Який відсоток ваших військ відправляти в наступ (1–100%)", "territory_patterns_label": "🏳️ Скіни території", "territory_patterns_desc": "Виберіть, чи показувати скіни територій у грі", "performance_overlay_label": "Оверлей продуктивности", - "performance_overlay_desc": "Увімкнення/вимкнення оверлея продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб увімкнути/вимкнути його.", + "performance_overlay_desc": "Перемкнути оверлей продуктивности. Якщо увімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб перемкнути його.", "easter_writing_speed_label": "Множник швидкості друку", "easter_writing_speed_desc": "Налаштуйте швидкість, з якою ви удаєте, що програмуєте (x1–x100)", "easter_bug_count_label": "Кількість багів", "easter_bug_count_desc": "Кількість багів, що ви вважаєте прийнятною (0–1000, емоційно)", + "press_a_key": "Натисніть клавішу", "view_options": "Налаштування вигляду", - "toggle_view": "Змінити вигляд", + "toggle_view": "Перемкнути вигляд", "toggle_view_desc": "Альтернативний вигляд (рельєф/країни)", "build_controls": "Керування розміщенням", "build_city": "Розмістити місто", "build_city_desc": "Розмістити місто під указівником.", "build_factory": "Розмістити фабрику", - "build_factory_desc": "Будувати фабрику під указівником.", + "build_factory_desc": "Розмістити фабрику під указівником.", "build_defense_post": "Розмістити пункт оборони", "build_defense_post_desc": "Розмістити пункт оборони під указівником.", "build_port": "Розмістити порт", "build_port_desc": "Розмістити порт під указівником.", "build_warship": "Розмістити військовий корабель", - "build_warship_desc": "Будувати військовий корабель під указівником.", + "build_warship_desc": "Розмістити військовий корабель під указівником.", "build_missile_silo": "Розмістити ракетну шахту", "build_missile_silo_desc": "Розмістити ракетну шахту під указівником.", "build_sam_launcher": "Розмістити ПУ ЗРК", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "Розмістити водневу бомбу під указівником.", "build_mirv": "Розмістити РГЧ ІН", "build_mirv_desc": "Розмістити РГЧ ІН під указівником.", + "menu_shortcuts": "Скорочення меню", + "build_menu_modifier": "Модифікатор меню будівництва", + "build_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню будівництва.", + "emoji_menu_modifier": "Модифікатор меню емоджі", + "emoji_menu_modifier_desc": "Утримуйте цю клавішу під час клацання, щоб відкрити меню емоджі.", "attack_ratio_controls": "Керування коефіцієнтом атаки", "attack_ratio_up": "Збільшити коефіцієнт атаки", "attack_ratio_up_desc": "Збільшити коефіцієнт атаки на 10%", @@ -392,6 +500,8 @@ "boat_attack_desc": "Відправити човен на клітинку під указівником.", "ground_attack": "Наземна атака", "ground_attack_desc": "Відправити наземну атаку на клітинку під указівником.", + "swap_direction": "Змінити напрямок ракети", + "swap_direction_desc": "Перемкнути напрямок ракети (угору/вниз).", "zoom_controls": "Масштабування", "zoom_out": "Зменшити масштаб", "zoom_out_desc": "Зменшити масштаб мапи", @@ -412,11 +522,12 @@ "unbind": "Звільнити", "on": "Увімкнено", "off": "Вимкнено", - "toggle_terrain": "Увімкнення/вимкнення рельєфу", + "toggle_terrain": "Перемикання рельєфу", "exit_game_label": "Вийти з гри", "exit_game_info": "Повернутися до головного меню", "background_music_volume": "Гучність фонової музики", - "sound_effects_volume": "Гучність звукових ефектів" + "sound_effects_volume": "Гучність звукових ефектів", + "keybind_conflict_error": "Клавішу {key} вже привʼязано до іншої дії." }, "chat": { "title": "Швидкий чат", @@ -516,7 +627,7 @@ "warship": "Захоплює торгові кораблі, знищує кораблі та човни", "port": "Відправляє торгові кораблі для генерації золота", "defense_post": "Підсилює оборону найближчих кордонів", - "city": "Збільшує максимальне населення", + "city": "Збільшує ліміт населення", "factory": "Прокладає залізничні колії та створює поїзди" }, "not_enough_money": "Недостатньо коштів" @@ -529,6 +640,7 @@ "other_team": "Команда «{team}» перемогла!", "you_won": "Ви перемогли!", "other_won": "Гравець {player} переміг!", + "nation_won": "Нація {nation} перемогла!", "exit": "Вийти з гри", "keep": "Продовжити гру", "spectate": "Спостерігати", @@ -537,19 +649,19 @@ "ofm_winter_description": "Приєднуйтеся до турніру та змагайтеся з найкращими гравцями", "join_tournament": "Приєднатися до турніру", "join_discord": "Приєднуйтеся до нашої спільноти Discord!", - "discord_description": "Спілкуйтеся з іншими гравцями, отримуйте новини та діліться стратегіями", + "discord_description": "Звʼязуйтеся з гравцями, відкривайте нові можливості та вигравайте призи!", "join_server": "Приєднатися до сервера", "youtube_tutorial": "Потрібна допомога?" }, "leaderboard": { "title": "Таблиця лідерів", "hide": "Приховати", - "rank": "Місце", + "rank": "Ранг", "player": "Гравець", "team": "Команда", "owned": "Влада", "gold": "Золото", - "troops": "Війська", + "maxtroops": "Ліміт військ", "launchers": "Установки", "sams": "ЗРК", "warships": "Військові кораблі", @@ -565,6 +677,7 @@ "team": "Команда", "alliance_timeout": "Кінець союзу через", "troops": "Війська", + "maxtroops": "Ліміт військ", "a_troops": "Наступальні війська", "gold": "Золото", "ports": "Порти", @@ -575,7 +688,9 @@ "warships": "Військові кораблі", "health": "Здоровʼя", "attitude": "Ставлення", - "levels": "Рівні" + "levels": "Рівні", + "wilderness_title": "Пустир", + "irradiated_wilderness_title": "Радіоактивний пустир" }, "events_display": { "retreating": "відступає", @@ -600,8 +715,21 @@ "alliance_renewed": "Союз із {name} було поновлено", "wants_to_renew_alliance": "{name} хоче поновити ваш союз", "ignore": "Ігнорувати", - "unit_voluntarily_deleted": "Обʼєкт добровільно видалено", - "betrayal_debuff_ends": "Залишилося {time} сек до закінчення покарання зрадника" + "unit_voluntarily_deleted": "Споруду добровільно видалено", + "betrayal_debuff_ends": "Залишилося {time} сек до закінчення покарання зрадника", + "attack_cancelled_retreat": "Атаку скасовано, {troops} солдатів загинули під час відступу", + "received_gold_from_captured_ship": "Отримано {gold} золота з корабля, захопленого у {name}", + "received_gold_from_trade": "Отримано {gold} золота від торгівлі з {name}", + "missile_intercepted": "{unit} перехоплює ракету", + "mirv_warheads_intercepted": "{count, plural, one {Перехоплено {count} боєголовку РГЧ ІН} few {Перехоплено {count} боєголовки РГЧ ІН} many {Перехоплено {count} боєголовок РГЧ ІН} other {Перехоплено {count} боєголовок РГЧ ІН}}", + "sent_troops_to_player": "Відправлено {troops} військ до {name}", + "received_troops_from_player": "Отримано {troops} військ від {name}", + "sent_gold_to_player": "Надіслано {gold} золота для {name}", + "received_gold_from_player": "Отримано {gold} золота від {name}", + "unit_captured_by_enemy": "{name} захоплює вашу споруду «{unit}»", + "captured_enemy_unit": "Захоплено споруду «{unit}» у {name}", + "unit_destroyed": "Вашу споруду «{unit}» було знищено", + "no_boats_available": "Немає доступних човнів, максимум — {max}" }, "unit_info_modal": { "structure_info": "Інформація про споруду", @@ -653,7 +781,10 @@ "send_alliance": "Надіслати союз", "send_troops": "Надіслати війська", "send_gold": "Надіслати золото", - "emotes": "Емоджі" + "emotes": "Емоджі", + "arc_up": "Верхня дуга", + "arc_down": "Нижня дуга", + "flip_rocket_trajectory": "Обернути траєкторію ракети" }, "send_troops_modal": { "title_with_name": "Надіслати війська до {name}", @@ -672,7 +803,7 @@ "replay_panel": { "replay_speed": "Швидкість відтворення", "game_speed": "Швидкість гри", - "fastest_game_speed": "Максимальна" + "fastest_game_speed": "Макс." }, "error_modal": { "crashed": "Гра крашнулася!", @@ -698,27 +829,33 @@ "frame": "Кадр:", "tick_exec": "Виконання на тік:", "tick_delay": "Затримка на тік:", - "layers_header": "Шари (сер. / макс., відсортовано за заг. часом):" + "layers_header": "Шари (сер. / макс., відсортовано за загальним часом):" }, "heads_up_message": { "choose_spawn": "Оберіть стартове розташування", - "random_spawn": "Випадкову появу увімкнено. Обираємо стартове розташування за вас..." + "random_spawn": "Випадкову появу увімкнено. Обираємо стартове розташування за вас...", + "singleplayer_game_paused": "Гру призупинено", + "multiplayer_game_paused": "Гра призупинена творцем лобі" }, "territory_patterns": { "title": "Скіни", "colors": "Кольори", "purchase": "Придбати", "show_only_owned": "Мої скіни", + "all_owned": "Усі скіни придбані! Повертайтеся пізніше за новими товарами.", + "not_logged_in": "Вхід не здійснено", "blocked": { "login": "Ви повинні ввійти, щоб отримати доступ до цього скіна.", "purchase": "Придбайте цей скін, щоб розблокувати його." }, "pattern": { "default": "Типово" - } + }, + "select_skin": "Оберіть скін", + "selected": "обрано" }, "flag_input": { - "title": "Виберіть прапор", + "title": "Вибір прапора", "button_title": "Обери прапор!", "search_flag": "Пошук..." }, @@ -732,8 +869,8 @@ "contact_admin": "Якщо ви вважаєте, що бачите це повідомлення помилково, зверніться до адміністратора сайту." }, "radial_menu": { - "delete_unit_title": "Видалити обʼєкт", - "delete_unit_description": "Клацніть, щоб видалити найближчий обʼєкт" + "delete_unit_title": "Видалити споруду", + "delete_unit_description": "Клацніть, щоб видалити найближчу споруду" }, "discord_user_header": { "avatar_alt": "Аватар" @@ -743,7 +880,7 @@ "ship_arrivals": "Прибуття кораблів", "nuke_stats": "Статистика бомбардувань", "player_metrics": "Статистика гравця", - "building": "Будівництво", + "building": "Споруда", "ship_type": "Тип корабля", "weapon": "Зброя", "built": "Побудовано", @@ -762,7 +899,7 @@ "gold": "Золото", "workers": "Робітники", "war": "Війни", - "trade": "Обмін", + "trade": "Торгівля", "steal": "Украдено", "unit": { "city": "Місто", @@ -786,8 +923,9 @@ "mode": "Режим", "mode_ffa": "Усі проти всіх", "mode_team": "Команда", - "view": "Оглянути", + "replay": "Повтор", "details": "Подробиці", + "ranking": "Рейтинг", "started": "Почато", "map": "Мапа", "difficulty": "Складність", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "Публічний", "private": "Приватний", - "singleplayer": "Гра наодинці", + "singleplayer": "Соло", "mode": "Режим", "stats_wins": "Перемоги", "stats_losses": "Поразки", - "stats_wlr": "Співвідношення перемог і поразок", + "stats_wlr": "Коефіцієнт перемог і поразок", "stats_games_played": "Зіграні ігри", "mode_ffa": "Усі проти всіх", - "mode_team": "Команда" + "mode_team": "Команда", + "no_stats": "Немає даних для цієї вибірки." + }, + "matchmaking_button": { + "play_ranked": "Рейтинговий підбір 1v1", + "description": "(АЛЬФА)", + "login_required": "Увійдіть, щоб грати в рейтинговому режимі!", + "must_login": "Ви повинні увійти, щоб грати в рейтинговому режимі." } } diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json index 1952eacd3..c090cd319 100644 --- a/resources/lang/zh-CN.json +++ b/resources/lang/zh-CN.json @@ -7,6 +7,7 @@ }, "common": { "close": "关闭", + "back": "返回", "available": "剩余", "preset_max": "最大", "summary_send": "发送", @@ -17,26 +18,42 @@ "cap_tooltip": "接收者的可接收数量", "target_dead": "目标已淘汰", "target_dead_note": "你不能向已淘汰玩家发送资源。", - "none": "空" + "none": "空", + "copied": "已复制!", + "click_to_copy": "点击复制" }, "main": { - "title": "OpenFront (ALPHA)", + "title": "OpenFront (内测版)", "join_discord": "Discord", "login_discord": "用 Discord 登录", + "sign_in": "登录", + "discord_avatar_alt": "Discord 头像", + "user_avatar_alt": "{username} 的头像", "checking_login": "正在检查登录...", "logged_in": "登录成功!", "log_out": "退出登录", - "create_lobby": "创建房间", - "join_lobby": "加入房间", - "single_player": "单人游戏", + "create": "创建房间", + "join": "加入房间", + "solo": "单人模式", "instructions": "操作说明", + "game_info": "游戏信息", "wiki": "游戏百科", "privacy_policy": "隐私政策", "terms_of_service": "服务条款", - "reddit": "Reddit" + "copyright": "© OpenFront™ 和贡献者们", + "reddit": "Reddit", + "play": "游戏", + "news": "公告", + "store": "商店", + "settings": "设置", + "keys": "按键", + "stats": "统计", + "account": "账号", + "help": "帮助", + "menu": "菜单", + "pick_pattern": "选择一个图案!" }, "news": { - "see_all_releases": "查看所有版本信息", "github_link": "在 Github 上", "title": "发行说明" }, @@ -83,6 +100,8 @@ "radial_attack": "打开攻击菜单。", "radial_info": "打开信息菜单。", "radial_boat": "发送一艘运输船攻击选中的区域。仅当你与水域毗邻时才可用。", + "radial_donate_troops": "捐赠相当于你攻击比例的军队给该盟友。", + "radial_donate_gold": "打开黄金捐赠菜单,可快速向盟友发送黄金。", "radial_close": "关闭菜单。", "info_title": "信息菜单", "info_enemy_desc": "包含以下信息:所选玩家的名称、黄金数量、军队数量、是否已停止与你贸易、是否对你发射了核弹,以及该玩家是否为叛徒。“停止贸易”表示你将无法从该玩家处获得金币,对方也无法通过商船向你发送金币。这种状态可能是手动触发(该玩家点击了“停止贸易”,此状态将持续,直到你们双方都点击“开始贸易”)或自动触发(当你背叛了联盟时,此状态会持续,直到你们重新结盟或5分钟后自动结束)。当玩家背叛并攻击其盟友时,“叛徒”状态将显示为“是”,持续30秒。下方图标表示你与该玩家的互动关系:", @@ -114,7 +133,7 @@ "build_silo": "导弹发射井", "build_silo_desc": "允许发射导弹。", "build_sam": "防空塔", - "build_sam_desc": "可以截获100像素范围内的敌方导弹。原子弹、氢弹和单个MIRV弹头的拦截命中概率分别是100%、80%和50%。该防空导弹拥有7.5秒冷却。", + "build_sam_desc": "可以截获100像素范围内的敌方导弹。防空塔有7.5秒的冷却时间。", "build_atom": "原子弹", "build_atom_desc": "小型爆弹可摧毁领土、建筑、船只。从最近的导弹发射井发射并坠落在你初次点击部署它的区域。", "build_hydrogen": "氢弹", @@ -129,12 +148,15 @@ "icon_embargo": "美元符号停止标志 - 禁商。该玩家已自动或手动停止与您的交易。", "icon_request": "信封 - 结盟请求。该玩家已向你发送结盟请求。", "info_enemy_panel": "敌人信息面板", - "exit_confirmation": "确定要退出游戏吗?" + "exit_confirmation": "确定要退出游戏吗?", + "bomb_direction": "原子弹 / 氢弹抛物线方向" }, "single_modal": { - "title": "单人玩家", + "title": "单人模式", "random_spawn": "随机出生点", "allow_alliances": "允许结盟", + "toggle_achievements": "切换成就", + "sign_in_for_achievements": "登录以获取成就", "options_title": "选项", "bots": "机器人: ", "bots_disabled": "已禁用", @@ -145,6 +167,8 @@ "infinite_troops": "无限军队", "compact_map": "紧凑地图", "max_timer": "游戏时长(分钟)", + "max_timer_placeholder": "分钟", + "max_timer_invalid": "请输入一个有效的最大计时器值(1-120分钟)", "disable_nukes": "禁用核弹", "enables_title": "启用设置", "start": "开始游戏" @@ -156,10 +180,21 @@ }, "account_modal": { "title": "账号", - "logged_in_as": "以 {email} 身份登录成功", + "connected_as": "已连接为", + "stats_overview": "统计概览", + "link_discord": "链接 Discord 帐号", + "log_out": "退出登录", + "sign_in_desc": "登录以保存您的统计数据和进度", + "or": "或", + "email_placeholder": "请输入您的电子邮件地址", + "get_magic_link": "获取魔法链接", + "linked_account": "以 {account_name} 身份登录成功", "fetching_account": "正在获取帐户信息......", - "logged_in_with_discord": "使用 Discord 登录", - "recovery_email_sent": "账号找回邮件已发送至 {email}" + "recovery_email_sent": "账号找回邮件已发送至 {email}", + "not_found": "未找到", + "clear_session": "清除会话", + "failed_to_send_recovery_email": "发送恢复邮件失败", + "enter_email_address": "请输入电子邮件地址" }, "stats_modal": { "title": "统计", @@ -167,11 +202,40 @@ "loading": "正在加载……", "error": "加载军团统计数据时出错", "no_stats": "暂无军团统计数据", + "no_data_yet": "暂无数据", "clan": "军团", "games": "游戏场数", "win_score": "胜者积分", + "win_score_tooltip": "加权胜场数基于战队参与度和比赛难度计算", "loss_score": "败者积分", - "win_loss_ratio": "胜负比" + "loss_score_tooltip": "加权败场数基于战队参与度和比赛难度计算", + "win_loss_ratio": "胜负比", + "ratio": "比率", + "rank": "排名", + "try_again": "再试一次" + }, + "game_info_modal": { + "title": "游戏信息", + "players": "玩家", + "atoms": "原子弹", + "hydros": "氢弹", + "mirv": "MIRV", + "bombs": "炸弹", + "total_gold": "总计", + "all_gold": "总黄金", + "trade": "交易", + "conquest_gold": "已抢夺黄金", + "stolen_gold": "被军舰偷走", + "num_of_conquests": "征服的玩家数", + "duration": "时长", + "survival_time": "存活时长", + "war": "战争", + "economy": "经济", + "conquests": "征服数", + "pirate": "抢劫", + "conquered": "被征服", + "loading_game_info": "正在加载游戏统计数据", + "no_winner": "这场游戏最终无人胜出(或者一个人机国获胜了)" }, "map": { "map": "地图", @@ -186,6 +250,7 @@ "asia": "亚洲", "mars": "火星", "southamerica": "南美洲", + "britanniaclassic": "不列颠尼亚(经典)", "britannia": "不列颠尼亚", "gatewaytotheatlantic": "大西洋枢纽", "australia": "澳大利亚", @@ -196,7 +261,7 @@ "betweentwoseas": "二海之间", "faroeislands": "法罗群岛", "deglaciatedantarctica": "冰消的南极洲", - "europeclassic": "欧洲 (经典)", + "europeclassic": "欧洲(经典)", "falklandislands": "福克兰群岛", "baikal": "贝加尔湖", "halkidiki": "哈尔基季基", @@ -206,19 +271,33 @@ "yenisei": "叶尼塞河", "pluto": "冥王星", "montreal": "蒙特利尔", + "newyorkcity": "纽约城", "achiran": "阿基尔岛/阿伦群岛", "baikalnukewars": "贝加尔湖(核战争)", "fourislands": "四岛争霸", "gulfofstlawrence": "圣劳伦斯湾", - "lisbon": "里斯本" + "lisbon": "里斯本", + "svalmel": "斯瓦尔梅尔", + "manicouagan": "马尼夸根陨石坑", + "lemnos": "利姆诺斯岛", + "sierpinski": "谢尔宾斯基分形", + "twolakes": "双湖", + "straitofhormuz": "霍尔木兹海峡", + "surrounded": "环岛", + "didier": "迪迪埃", + "didierfrance": "迪迪埃(法国)", + "amazonriver": "亚马逊河" }, "map_categories": { "continental": "大陆", "regional": "地区", - "fantasy": "其他" + "fantasy": "其他", + "special": "特殊", + "arcade": "街机" }, "map_component": { - "loading": "正在加载..." + "loading": "正在加载...", + "error": "错误" }, "private_lobby": { "title": "加入私人房间", @@ -229,42 +308,55 @@ "checking": "正在确认房间...", "not_found": "找不到房间。请检查 ID 然后重试。", "error": "发生错误。请再试一次或联系支持人员。", - "joined_waiting": "加入成功!正在等待游戏开始...", - "version_mismatch": "这场游戏基于另一个版本,无法加入。" + "joined_waiting": "房间已加入!等待房主开始游戏……", + "version_mismatch": "这场游戏基于另一个版本,无法加入。", + "disabled_units": "禁用单位" }, "public_lobby": { "join": "加入下一场游戏", "waiting": "等待中的玩家", - "teams_Duos": "/ 2(2人小队)", - "teams_Trios": "/ 3(3人小队)", - "teams_Quads": "/ 4(4人小队)", + "teams_Duos": "{team_count} 个 2 人小队", + "teams_Trios": "{team_count} 个 3 人小队", + "teams_Quads": "{team_count} 个 4 人小队", + "waiting_for_players": "正在等待玩家", + "starting_game": "正在启动游戏……", "teams_hvn": "人类 VS 国家", + "teams_hvn_detailed": "{num} 个人类 VS {num} 个国家", "teams": "{num} 个队伍", - "players_per_team": "/ {num}" + "players_per_team": "每队 {num} 人", + "started": "已开始" }, "matchmaking_modal": { - "title": "匹配中", + "title": "1v1 排位赛(内测版)", "connecting": "正在连接到匹配服务器……", "searching": "正在搜索游戏……", - "waiting_for_game": "正在等待游戏开始……" + "waiting_for_game": "正在等待游戏开始……", + "elo": "你的 ELO 分:{elo}" }, "username": { "enter_username": "输入用户名", "not_string": "用户名必须是字符串。", "too_short": "用户名最少包含 {min} 个字符。", "too_long": "用户名不得超过 {max} 个字符。", - "invalid_chars": "用户名只能包含字母、数字、空格、下划线和 [方括号]。" + "invalid_chars": "用户名只能包含字母、数字、空格、下划线和 [方括号]。", + "tag": "标签", + "tag_too_short": "战队标签必须是 2-5 位字母或数字字符。", + "tag_invalid_chars": "战队标签只能包含字母和数字。" }, "host_modal": { - "title": "私人房间", + "title": "创建私人房间", + "label": "私有", "mode": "模式", "team_count": "队伍数量", + "team_type": "队伍类型", "options_title": "选项", "bots": "机器人: ", "bots_disabled": "禁用", + "player_immunity_duration": "PVP 豁免期限(分钟)", "nations": "国家:", "disable_nations": "禁用国家", "max_timer": "游戏时长(分钟)", + "mins_placeholder": "分钟", "instant_build": "立即建造", "infinite_gold": "无限金钱", "donate_gold": "捐赠金币", @@ -283,7 +375,11 @@ "assigned_teams": "已分配的队伍", "empty_teams": "空队伍", "empty_team": "空", - "remove_player": "移除 {username}" + "remove_player": "移除 {username}", + "teams_Duos": "2人小队", + "teams_Trios": "3人小队", + "teams_Quads": "4人小队", + "teams_Humans Vs Nations": "人类 VS 国家" }, "team_colors": { "red": "红色", @@ -301,16 +397,20 @@ "code_license": "代码采用 AGPL-3.0 许可证授权(无担保)" }, "difficulty": { - "difficulty": "难度", - "Easy": "休闲", - "Medium": "平衡", - "Hard": "困难", - "Impossible": "地狱" + "difficulty": "国家难度", + "easy": "简单", + "medium": "中等", + "hard": "困难", + "impossible": "地狱" }, "game_mode": { "ffa": "混战", "teams": "团队" }, + "public_game_modifier": { + "random_spawn": "随机出生点", + "compact_map": "紧凑地图" + }, "select_lang": { "title": "选择语言" }, @@ -327,7 +427,7 @@ "factory": "工厂" }, "user_setting": { - "title": "用户设置", + "title": "设置", "tab_basic": "基本设置", "tab_keybinds": "热键绑定", "dark_mode_label": "深色模式", @@ -340,16 +440,18 @@ "special_effects_desc": "切换特效开关。停用以改进性能", "structure_sprites_label": "建筑贴图", "structure_sprites_desc": "切换建筑贴图", + "cursor_cost_label_label": "建造按钮显示消耗", + "cursor_cost_label_desc": "在建造按钮下显示花费", "anonymous_names_label": "隐藏的名称", "anonymous_names_desc": "将真实玩家名字替换为随机名字。", "lobby_id_visibility_label": "隐藏的房间ID", "lobby_id_visibility_desc": "在创建私人房间时隐藏房间ID", + "toggle_visibility": "切换是否可见", "left_click_label": "左键单击打开菜单", "left_click_desc": "开启时,先左键单击打开菜单,然后再点进攻。关闭时,左键将直接进攻。", "left_click_menu": "左键点击菜单", "attack_ratio_label": "⚔️ 攻击比例", "attack_ratio_desc": "你要派出多少比例的军队进攻 (1–100%)", - "troop_ratio_desc": "调整军队 (用于战斗) 和工人 (用于生产黄金) 之间的比例 (1-100%)", "territory_patterns_label": "🏳️ 领土皮肤", "territory_patterns_desc": "选择是否在游戏中显示领土皮肤", "performance_overlay_label": "性能叠层", @@ -358,6 +460,7 @@ "easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1–x100)", "easter_bug_count_label": "Bug 计数", "easter_bug_count_desc": "你能接受多少个 Bug? (0–1000,心理承受范围)", + "press_a_key": "按下一个按键", "view_options": "视图选项", "toggle_view": "切换视图", "toggle_view_desc": "备选视图 (地形/国家)", @@ -382,6 +485,11 @@ "build_hydrogen_bomb_desc": "向鼠标位置发射氢弹。", "build_mirv": "发射 MIRV", "build_mirv_desc": "向鼠标位置发射 MIRV。", + "menu_shortcuts": "菜单快捷键", + "build_menu_modifier": "建造菜单编辑器", + "build_menu_modifier_desc": "按住此键并点击以打开建造菜单。", + "emoji_menu_modifier": "Emoji 表情菜单编辑器", + "emoji_menu_modifier_desc": "按住此键并点击以打开 Emoji 表情菜单。", "attack_ratio_controls": "攻击比例控制", "attack_ratio_up": "增加攻击比例", "attack_ratio_up_desc": "增加 10% 攻击比例", @@ -392,6 +500,8 @@ "boat_attack_desc": "向鼠标所指地块发送船只攻击。", "ground_attack": "对地攻击", "ground_attack_desc": "向鼠标所指地块发送船只攻击。", + "swap_direction": "调换火箭方向", + "swap_direction_desc": "切换火箭发射方向(上/下)。", "zoom_controls": "缩放控制", "zoom_out": "缩小", "zoom_out_desc": "缩小地图", @@ -416,7 +526,8 @@ "exit_game_label": "退出游戏", "exit_game_info": "返回主菜单", "background_music_volume": "背景音量", - "sound_effects_volume": "音效音量" + "sound_effects_volume": "音效音量", + "keybind_conflict_error": "按键 {key} 已经绑定到另一动作上了。" }, "chat": { "title": "快捷聊天", @@ -529,6 +640,7 @@ "other_team": "{team} 队获胜了!", "you_won": "你获胜了!", "other_won": "{player} 获胜了!", + "nation_won": "国家 {nation} 获胜了!", "exit": "退出游戏", "keep": "继续游戏", "spectate": "观战", @@ -537,7 +649,7 @@ "ofm_winter_description": "加入竞技比赛,与最强玩家一较高下", "join_tournament": "加入比赛", "join_discord": "加入我们的 Discord 社区!", - "discord_description": "与其他玩家交流,获取最新消息,分享游戏战略", + "discord_description": "与玩家交流,发现新功能,并赢取奖品!", "join_server": "加入服务器", "youtube_tutorial": "需要帮助吗?" }, @@ -549,7 +661,7 @@ "team": "队伍", "owned": "已占领", "gold": "黄金", - "troops": "军队", + "maxtroops": "最大军队", "launchers": "导弹发射井", "sams": "防空塔", "warships": "军舰", @@ -565,6 +677,7 @@ "team": "队伍", "alliance_timeout": "结盟剩余时长", "troops": "军队", + "maxtroops": "最大军队", "a_troops": "进攻军队", "gold": "黄金", "ports": "港口", @@ -575,7 +688,9 @@ "warships": "军舰", "health": "生命值", "attitude": "态度", - "levels": "等级" + "levels": "等级", + "wilderness_title": "荒野", + "irradiated_wilderness_title": "受辐射的荒野" }, "events_display": { "retreating": "正在撤退", @@ -601,7 +716,20 @@ "wants_to_renew_alliance": "{name} 想与你续签盟约", "ignore": "忽略", "unit_voluntarily_deleted": "单位已自毁", - "betrayal_debuff_ends": "距离背叛减益效果结束还剩 {time} 秒" + "betrayal_debuff_ends": "距离背叛减益效果结束还剩 {time} 秒", + "attack_cancelled_retreat": "已取消进攻,在撤退时损失了 {troops} 兵力", + "received_gold_from_captured_ship": "捕获了 {name} 的商船,获得 {gold} 黄金", + "received_gold_from_trade": "与 {name} 贸易获得了 {gold} 黄金", + "missile_intercepted": "已拦截导弹 {unit}", + "mirv_warheads_intercepted": "{count, plural, one {{count} 个 MIRV 弹头被拦截} other {{count} 个 MIRV 弹头被拦截}}", + "sent_troops_to_player": "已向 {name} 发送 {troops} 军队", + "received_troops_from_player": "已从 {name} 收到 {troops} 军队", + "sent_gold_to_player": "已向 {name} 发送 {gold} 黄金", + "received_gold_from_player": "已从 {name} 收到 {gold} 黄金", + "unit_captured_by_enemy": "你的 {unit} 被 {name} 捕获", + "captured_enemy_unit": "已捕获 {name} 的 {unit}", + "unit_destroyed": "你的 {unit} 已被摧毁", + "no_boats_available": "无可用船,最多 {max} 个" }, "unit_info_modal": { "structure_info": "建筑信息", @@ -653,7 +781,10 @@ "send_alliance": "请求结盟", "send_troops": "发送军队", "send_gold": "发送黄金", - "emotes": "表情符号" + "emotes": "表情符号", + "arc_up": "向上的弧", + "arc_down": "向下的弧", + "flip_rocket_trajectory": "翻转火箭轨道" }, "send_troops_modal": { "title_with_name": "向 {name} 发送军队", @@ -702,20 +833,26 @@ }, "heads_up_message": { "choose_spawn": "选择出生点", - "random_spawn": "随机出生点已启用。正在为你选择出生点……" + "random_spawn": "随机出生点已启用。正在为你选择出生点……", + "singleplayer_game_paused": "游戏已暂停", + "multiplayer_game_paused": "游戏已被房主暂停" }, "territory_patterns": { "title": "皮肤", "colors": "颜色", "purchase": "购买", "show_only_owned": "我的皮肤", + "all_owned": "您已拥有所有皮肤!请稍后再来查看新皮肤。", + "not_logged_in": "未登录", "blocked": { "login": "您必须登录才能使用此皮肤。", "purchase": "购买以解锁此皮肤。" }, "pattern": { "default": "默认" - } + }, + "select_skin": "选择皮肤", + "selected": "已选择" }, "flag_input": { "title": "选择旗帜", @@ -786,8 +923,9 @@ "mode": "模式", "mode_ffa": "混战", "mode_team": "团队", - "view": "查看", + "replay": "回放", "details": "详情", + "ranking": "排行", "started": "已开始", "map": "地图", "difficulty": "难度", @@ -796,13 +934,20 @@ "player_stats_tree": { "public": "公开", "private": "私有", - "singleplayer": "单人玩家", + "singleplayer": "单人模式", "mode": "模式", "stats_wins": "胜场数", "stats_losses": "败场数", "stats_wlr": "胜败比", "stats_games_played": "游戏场数", "mode_ffa": "混战", - "mode_team": "团队" + "mode_team": "团队", + "no_stats": "所选项没有统计记录。" + }, + "matchmaking_button": { + "play_ranked": "1v1 排位赛", + "description": "(内测版)", + "login_required": "登录后开始排位赛!", + "must_login": "您必须登录才能玩排位赛。" } } diff --git a/resources/maps/britannia/manifest.json b/resources/maps/britannia/manifest.json index 7a0db2a33..415dac416 100644 --- a/resources/maps/britannia/manifest.json +++ b/resources/maps/britannia/manifest.json @@ -113,7 +113,6 @@ }, { "coordinates": [404, 1146], - "flag": "gb-nir", "name": "Fermanagh" } ] diff --git a/resources/maps/italia/manifest.json b/resources/maps/italia/manifest.json index d8242ba2b..ed721cb36 100644 --- a/resources/maps/italia/manifest.json +++ b/resources/maps/italia/manifest.json @@ -18,7 +18,6 @@ "nations": [ { "coordinates": [1038, 993], - "flag": "custom:Kingdom of the Two Sicilies", "name": "Kingdom of the Two Sicilies" }, { @@ -33,7 +32,6 @@ }, { "coordinates": [625, 534], - "flag": "custom:Tuscany", "name": "Tuscany" }, { @@ -43,12 +41,10 @@ }, { "coordinates": [469, 386], - "flag": "custom:Modena", "name": "Modena" }, { "coordinates": [391, 254], - "flag": "custom:Parma", "name": "Parma" }, { @@ -58,7 +54,6 @@ }, { "coordinates": [278, 774], - "flag": "custom:Kingdom of Sardinia", "name": "Kingdom of Sardinia" }, { @@ -73,7 +68,6 @@ }, { "coordinates": [1238, 349], - "flag": "custom:Ottoman Empire2", "name": "Ottoman Empire" } ] diff --git a/resources/maps/montreal/manifest.json b/resources/maps/montreal/manifest.json index 78ce3f637..4c17fa2f8 100644 --- a/resources/maps/montreal/manifest.json +++ b/resources/maps/montreal/manifest.json @@ -18,17 +18,17 @@ "nations": [ { "coordinates": [800, 430], - "flag": "quebec", + "flag": "Quebec", "name": "Laval" }, { "coordinates": [1110, 930], - "flag": "quebec", + "flag": "Quebec", "name": "Royal Mount park" }, { "coordinates": [1220, 1360], - "flag": "quebec", + "flag": "Quebec", "name": "Hochelaga Archipelago" }, { @@ -38,42 +38,42 @@ }, { "coordinates": [1400, 1000], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Lambert" }, { "coordinates": [500, 130], - "flag": "quebec", + "flag": "Quebec", "name": "Blainville" }, { "coordinates": [350, 650], - "flag": "quebec", + "flag": "Quebec", "name": "Saint-Eustache" }, { "coordinates": [200, 1350], - "flag": "quebec", + "flag": "Quebec", "name": "Perrot Island" }, { "coordinates": [25, 950], - "flag": "quebec", + "flag": "Quebec", "name": "Kanesatake Lands" }, { "coordinates": [50, 450], - "flag": "quebec", + "flag": "Quebec", "name": "Mirabel" }, { "coordinates": [650, 1450], - "flag": "quebec", + "flag": "Quebec", "name": "Chateauguay" }, { "coordinates": [1330, 300], - "flag": "quebec", + "flag": "Quebec", "name": "Pointe-aux-Trembles" } ] diff --git a/resources/maps/straitofgibraltar/manifest.json b/resources/maps/straitofgibraltar/manifest.json index 930ef92a5..790ecf015 100644 --- a/resources/maps/straitofgibraltar/manifest.json +++ b/resources/maps/straitofgibraltar/manifest.json @@ -18,7 +18,6 @@ "nations": [ { "coordinates": [1941, 1031], - "flag": "Rif", "name": "Rif" }, { @@ -43,12 +42,10 @@ }, { "coordinates": [1271, 1393], - "flag": "Shilha", "name": "Shilha" }, { "coordinates": [1555, 258], - "flag": "Andalusia", "name": "Andalusia" } ] diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 505537be6..8169e9566 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -13,16 +13,16 @@ import "./components/baseComponents/stats/GameList"; import "./components/baseComponents/stats/PlayerStatsTable"; import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; import "./components/PatternButton"; import { modalHeader } from "./components/ui/ModalHeader"; -import { copyToClipboard, translateText } from "./Utils"; +import { translateText } from "./Utils"; @customElement("account-modal") export class AccountModal extends BaseModal { @state() private email: string = ""; @state() private isLoadingUser: boolean = false; - @state() private showCopied: boolean = false; private userMeResponse: UserMeResponse | null = null; private statsTree: PlayerStatsTree | null = null; @@ -47,17 +47,6 @@ export class AccountModal extends BaseModal { }); } - private async copyIdToClipboard() { - const id = this.userMeResponse?.player?.publicId; - if (!id) return; - - await copyToClipboard( - id, - () => (this.showCopied = true), - () => (this.showCopied = false), - ); - } - private hasAnyStats(): boolean { if (!this.statsTree) return false; // Check if statsTree has any data @@ -106,6 +95,8 @@ export class AccountModal extends BaseModal { private renderInner() { const isLoggedIn = !!this.userMeResponse?.user; const title = translateText("account_modal.title"); + const publicId = this.userMeResponse?.player?.publicId ?? ""; + const displayId = publicId || translateText("account_modal.not_found"); return html`
ID:${translateText("account_modal.personal_player_id")} - +
` : undefined, diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 62e16b655..d7d0d7e8c 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -70,7 +70,7 @@ export function joinLobby( lobbyConfig: LobbyConfig, onPrestart: () => void, onJoin: () => void, -): () => void { +): (force?: boolean) => boolean { console.log( `joining lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}`, ); @@ -80,6 +80,8 @@ export function joinLobby( const transport = new Transport(lobbyConfig, eventBus); + let currentGameRunner: ClientGameRunner | null = null; + let hasJoined = false; const onconnect = () => { @@ -123,9 +125,15 @@ export function joinLobby( terrainLoad, terrainMapFileLoader, ) - .then((r) => r.start()) + .then((r) => { + currentGameRunner = r; + r.start(); + }) .catch((e) => { console.error("error creating client game", e); + + currentGameRunner = null; + const startingModal = document.querySelector( "game-starting-modal", ) as HTMLElement; @@ -166,9 +174,19 @@ export function joinLobby( } }; transport.connect(onconnect, onmessage); - return () => { + return (force: boolean = false) => { + if (!force && currentGameRunner?.shouldPreventWindowClose()) { + console.log("Player is active, prevent leaving game"); + + return false; + } + console.log("leaving game"); + + currentGameRunner = null; transport.leaveGame(); + + return true; }; } @@ -257,6 +275,21 @@ export class ClientGameRunner { this.lastMessageTime = Date.now(); } + /** + * Determines whether window closing should be prevented. + * + * Used to show a confirmation dialog when the user attempts to close + * the window or navigate away during an active game session. + * + * @returns {boolean} `true` if the window close should be prevented + * (when the player is alive in the game), `false` otherwise + * (when the player is not alive or doesn't exist) + */ + public shouldPreventWindowClose(): boolean { + // Show confirmation dialog if player is alive in the game + return !!this.myPlayer?.isAlive(); + } + private async saveGame(update: WinUpdate) { if (this.myPlayer === null) { return; @@ -671,17 +704,12 @@ export class ClientGameRunner { private sendBoatAttackIntent(tile: TileRef) { if (!this.myPlayer) return; - this.myPlayer.bestTransportShipSpawn(tile).then((spawn: number | false) => { - if (this.myPlayer === null) throw new Error("not initialized"); - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.gameView.owner(tile).id(), - tile, - this.myPlayer.troops() * this.renderer.uiState.attackRatio, - spawn === false ? null : spawn, - ), - ); - }); + this.eventBus.emit( + new SendBoatAttackIntentEvent( + tile, + this.myPlayer.troops() * this.renderer.uiState.attackRatio, + ), + ); } private async sendAttackIntent(tile: TileRef) { @@ -755,6 +783,9 @@ function showErrorModal( return; } + const translatedError = translateText(error); + const displayError = translatedError === error ? error : translatedError; + const modal = document.createElement("div"); modal.id = "error-modal"; @@ -763,7 +794,7 @@ function showErrorModal( translateText(heading), `game id: ${gameID}`, `client id: ${clientID}`, - `Error: ${error}`, + `Error: ${displayError}`, message ? `Message: ${message}` : null, ] .filter(Boolean) diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 821d8e25e..d1f00e88d 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -29,23 +29,30 @@ export async function handlePurchase( window.location.href = url; } +let __cosmetics: Promise | null = null; export async function fetchCosmetics(): Promise { - try { - const response = await fetch(`${getApiBase()}/cosmetics.json`); - if (!response.ok) { - console.error(`HTTP error! status: ${response.status}`); - return null; - } - const result = CosmeticsSchema.safeParse(await response.json()); - if (!result.success) { - console.error(`Invalid cosmetics: ${result.error.message}`); - return null; - } - return result.data; - } catch (error) { - console.error("Error getting cosmetics:", error); - return null; + if (__cosmetics !== null) { + return __cosmetics; } + __cosmetics = (async () => { + try { + const response = await fetch(`${getApiBase()}/cosmetics.json`); + if (!response.ok) { + console.error(`HTTP error! status: ${response.status}`); + return null; + } + const result = CosmeticsSchema.safeParse(await response.json()); + if (!result.success) { + console.error(`Invalid cosmetics: ${result.error.message}`); + return null; + } + return result.data; + } catch (error) { + console.error("Error getting cosmetics:", error); + return null; + } + })(); + return __cosmetics; } export function patternRelationship( diff --git a/src/client/CrazyGamesSDK.ts b/src/client/CrazyGamesSDK.ts index b00cfe7de..1933074ec 100644 --- a/src/client/CrazyGamesSDK.ts +++ b/src/client/CrazyGamesSDK.ts @@ -3,6 +3,28 @@ declare global { CrazyGames?: { SDK: { init: () => Promise; + user: { + getUser(): Promise<{ + username: string; + } | null>; + addAuthListener: ( + listener: ( + user: { + username: string; + } | null, + ) => void, + ) => void; + }; + ad: { + requestAd: ( + adType: string, + callbacks: { + adStarted: () => void; + adFinished: () => void; + adError: (error: any) => void; + }, + ) => void; + }; game: { gameplayStart: () => Promise; gameplayStop: () => Promise; @@ -14,7 +36,9 @@ declare global { [key: string]: string | number; }) => string; hideInviteButton: () => void; + inviteLink: (params: { [key: string]: string | number }) => string; getInviteParam: (paramName: string) => string | null; + isInstantMultiplayer?: boolean; }; }; }; @@ -24,6 +48,24 @@ declare global { export class CrazyGamesSDK { private initialized = false; private isGameplayActive = false; + private readyPromise: Promise; + private resolveReady!: () => void; + + constructor() { + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); + } + + async ready(): Promise { + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(false), 3000); + }); + + const ready = this.readyPromise.then(() => true); + + return Promise.race([ready, timeout]); + } isOnCrazyGames(): boolean { try { @@ -34,9 +76,17 @@ export class CrazyGamesSDK { } return false; } catch (e) { + console.log("[CrazyGames]: ", e); // If we get a cross-origin error, we're definitely iframed // Check our own referrer as fallback - return document.referrer.includes("crazygames"); + const isCrazyGames = document.referrer.includes("crazygames"); + console.log("[CrazyGames], contains referrer: ", isCrazyGames); + if (isCrazyGames) { + return true; + } + + // Fallback: on safari private we can't get referrer, so just assume we are in crazygames if in iframe + return window.self !== window.top; } } @@ -70,12 +120,63 @@ export class CrazyGamesSDK { try { await window.CrazyGames.SDK.init(); this.initialized = true; + this.resolveReady(); console.log("CrazyGames SDK initialized"); } catch (error) { console.error("Failed to initialize CrazyGames SDK:", error); } } + async getUsername(): Promise { + const isReady = await this.ready(); + if (!isReady) { + return null; + } + try { + return (await window.CrazyGames!.SDK.user.getUser())?.username ?? null; + } catch (e) { + console.log("error getting CrazyGames username: ", e); + return null; + } + } + + async addAuthListener( + listener: ( + user: { + username: string; + } | null, + ) => void, + ): Promise { + if (!(await this.ready())) { + return; + } + + try { + console.log("registering CrazyGames auth listener"); + window.CrazyGames!.SDK.user.addAuthListener(listener); + } catch (error) { + console.error("Failed to add auth listener:", error); + } + } + + async isInstantMultiplayer(): Promise { + const isReady = await this.ready(); + if (!isReady) { + return false; + } + const gameId = await this.getInviteGameId(); + if (gameId !== null) { + // Game id exists, meaning we are joining the game, not hosting it. + return false; + } + try { + return window.CrazyGames!.SDK.game.isInstantMultiplayer ?? false; + } catch (e) { + console.log("Error getting instant multiplayer: ", e); + return false; + } + } + async gameplayStart(): Promise { if (!this.isReady()) { return; @@ -156,7 +257,6 @@ export class CrazyGamesSDK { if (!this.isReady()) { return null; } - try { const options: { gameId: string | number; @@ -165,6 +265,9 @@ export class CrazyGamesSDK { gameId, }; const link = window.CrazyGames!.SDK.game.showInviteButton(options); + // Store the game so we know that we are host. This way when player refreshes page, + // It won't show up as "joining" a game we created. + localStorage.setItem(gameId, "true"); console.log("CrazyGames: invite button shown, link:", link); return link; } catch (error) { @@ -186,20 +289,66 @@ export class CrazyGamesSDK { } } - getInviteGameId(): string | null { + createInviteLink(gameId: string): string | null { if (!this.isReady()) { + console.warn("CrazyGames SDK not ready, cannot create invite link"); return null; } try { - const value = window.CrazyGames!.SDK.game.getInviteParam("gameId"); - console.log(`CrazyGames: got invite gameId:`, value); - return value; + const link = window.CrazyGames!.SDK.game.inviteLink({ gameId }); + console.log("CrazyGames: created invite link:", link); + return link; + } catch (error) { + console.error("Failed to create invite link:", error); + return null; + } + } + + async getInviteGameId(): Promise { + if (!(await this.ready())) { + return null; + } + try { + const gameId = window.CrazyGames!.SDK.game.getInviteParam("gameId"); + if (gameId) { + console.log("[CrazyGames] found invite link", gameId); + // We already created this game, can't join a game we created. + return localStorage.getItem(gameId) === "true" ? null : gameId; + } + return null; } catch (error) { console.error(`Failed to get invite gameId:`, error); return null; } } + + requestMidgameAd(): Promise { + return new Promise((resolve) => { + if (!this.isReady()) { + resolve(); + return; + } + + try { + const callbacks = { + adFinished: () => { + console.log("End midgame ad"); + resolve(); + }, + adError: (error: any) => { + console.log("Error midgame ad", error); + resolve(); + }, + adStarted: () => console.log("Start midgame ad"), + }; + window.CrazyGames!.SDK.ad.requestAd("midgame", callbacks); + } catch (error) { + console.error("Failed to request midgame ad:", error); + resolve(); + } + }); + } } export const crazyGamesSDK = new CrazyGamesSDK(); diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts index a1f50e4b7..917665a93 100644 --- a/src/client/GameInfoModal.ts +++ b/src/client/GameInfoModal.ts @@ -49,7 +49,9 @@ export class GameInfoModal extends LitElement { title="${translateText("game_info_modal.title")}" translationKey="main.game_info" > -
+
${this.isLoadingGame ? this.renderLoadingAnimation() @@ -108,7 +110,7 @@ export class GameInfoModal extends LitElement { const isUnusualThumbnailSize = hasUnusualThumbnailSize(info.config.gameMap); return html`
${this.mapImage ? html` 0 ? this.score(this.rankedPlayers[0]) : 0; return html` -
    +
      - this.onUserMe((event as CustomEvent).detail); + static styles = css``; connectedCallback() { super.connectedCallback(); - document.addEventListener( - "userMeResponse", - this.boundUserMeHandler as EventListener, - ); - } - - private onUserMe(userMeResponse: UserMeResponse | false): void { - const flares = - userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); - const hasFlare = flares.some((flare) => flare.startsWith("pattern:")); - if (hasFlare) { - console.log("No ads because you have patterns"); - window.enableAds = false; - } else { - console.log("No flares, showing ads"); - this.show(); - window.enableAds = true; - } - } - - private isScreenLargeEnough(): boolean { - return window.innerWidth >= MIN_SCREEN_WIDTH; + document.addEventListener("userMeResponse", () => { + if (window.adsEnabled) { + console.log("showing gutter ads"); + this.show(); + } else { + console.log("not showing gutter ads"); + } + }); } // Called after the component's DOM is first rendered firstUpdated() { // DOM is guaranteed to be available here - console.log("GutterAd DOM is ready"); + console.log("GutterAdModal DOM is ready"); } public show(): void { - if (!this.isScreenLargeEnough()) { - console.log("Screen too small for gutter ads, skipping"); - return; - } - - if (isInIframe()) { - console.log("In iframe, showing gutter ads"); - return; - } - - console.log("showing GutterAds"); this.isVisible = true; this.requestUpdate(); @@ -74,58 +50,57 @@ export class GutterAds extends LitElement { }); } - public hide(): void { - this.isVisible = false; - console.log("hiding GutterAds"); - this.destroyAds(); - document.removeEventListener( - "userMeResponse", - this.boundUserMeHandler as EventListener, - ); - this.requestUpdate(); - } - private loadAds(): void { + console.log("loading ramp ads"); // Ensure the container elements exist before loading ads - const leftContainer = this.querySelector(`#${LEFT_FUSE}`); - const rightContainer = this.querySelector(`#${RIGHT_FUSE}`); + const leftContainer = this.querySelector(`#${this.leftContainerId}`); + const rightContainer = this.querySelector(`#${this.rightContainerId}`); if (!leftContainer || !rightContainer) { console.warn("Ad containers not found in DOM"); return; } - if (!window.fusetag) { - console.warn("Fuse tag not available"); + if (!window.ramp) { + console.warn("Playwire RAMP not available"); + return; + } + + if (this.adLoaded) { + console.log("Ads already loaded, skipping"); return; } try { - console.log("registering zones"); - window.fusetag.que.push(() => { - window.fusetag.registerZone(LEFT_FUSE); - window.fusetag.registerZone(RIGHT_FUSE); + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { + type: this.leftAdType, + selectorId: this.leftContainerId, + }, + { + type: this.rightAdType, + selectorId: this.rightContainerId, + }, + ]); + this.adLoaded = true; + console.log( + "Playwire ads loaded:", + this.leftAdType, + this.rightAdType, + ); + } catch (e) { + console.log(e); + } }); } catch (error) { - console.error("Failed to load fuse ads:", error); - this.hide(); + console.error("Failed to load Playwire ads:", error); } } - private destroyAds(): void { - if (!window.fusetag) { - return; - } - window.fusetag.que.push(() => { - window.fusetag.destroyZone(LEFT_FUSE); - window.fusetag.destroyZone(RIGHT_FUSE); - }); - this.requestUpdate(); - } - disconnectedCallback() { super.disconnectedCallback(); - this.hide(); } render() { @@ -134,11 +109,26 @@ export class GutterAds extends LitElement { } return html` -
      -
      + + -
      -
      + + + `; } diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 46e692e4e..ad0b1db2e 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -3,8 +3,8 @@ import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; -import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; +import { TroubleshootingModal } from "./TroubleshootingModal"; @customElement("help-modal") export class HelpModal extends BaseModal { @@ -105,13 +105,13 @@ export class HelpModal extends BaseModal { : ""}" > ${modalHeader({ - title: translateText("main.instructions"), + title: translateText("main.help"), onBack: this.close, ariaLabel: translateText("common.back"), })}
      + +
      +
      + + + + + +
      +

      + ${translateText("main.troubleshooting")} +

      +
      +
      +
      +
      +

      + ${translateText("help_modal.troubleshooting_desc")} +

      + +
      +
      @@ -1138,6 +1185,20 @@ export class HelpModal extends BaseModal { `; } + openTroubleshooting() { + const troubleshootingModal = document.querySelector( + "troubleshooting-modal", + ) as TroubleshootingModal; + if ( + !troubleshootingModal || + !(troubleshootingModal instanceof TroubleshootingModal) + ) { + console.warn("Troubleshooting modal element not found"); + return; + } + troubleshootingModal.open(); + } + protected onOpen(): void { this.keybinds = this.getKeybinds(); } diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 74cfbeaac..8b3e314f4 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,6 +1,6 @@ import { TemplateResult, html } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { copyToClipboard, translateText } from "../client/Utils"; +import { translateText } from "../client/Utils"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { Difficulty, @@ -12,10 +12,7 @@ import { Quads, Trios, UnitType, - mapCategories, } from "../core/game/Game"; -import { getCompactMapNationCount } from "../core/game/NationCreation"; -import { UserSettings } from "../core/game/UserSettings"; import { ClientInfo, GameConfig, @@ -26,20 +23,24 @@ import { import { generateID } from "../core/Util"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/LobbyTeamView"; -import "./components/Maps"; +import "./components/LobbyPlayerView"; +import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { + renderToggleInputCard, + renderToggleInputCardInput, +} from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; -import randomMap from "/images/RandomMap.webp?url"; @customElement("host-lobby-modal") export class HostLobbyModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; - @state() private selectedDifficulty: Difficulty = Difficulty.Medium; + @state() private selectedDifficulty: Difficulty = Difficulty.Easy; @state() private disableNations = false; @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; @@ -60,20 +61,21 @@ export class HostLobbyModal extends BaseModal { @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private lobbyId = ""; - @state() private copySuccess = false; @state() private lobbyUrlSuffix = ""; @state() private clients: ClientInfo[] = []; @state() private useRandomMap: boolean = false; @state() private disabledUnits: UnitType[] = []; @state() private lobbyCreatorClientID: string = ""; - @state() private lobbyIdVisible: boolean = true; @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; // Add a new timer for debouncing bot changes private botsUpdateTimer: number | null = null; - private userSettings: UserSettings = new UserSettings(); private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -113,6 +115,12 @@ export class HostLobbyModal extends BaseModal { } private async buildLobbyUrl(): Promise { + if (crazyGamesSDK.isOnCrazyGames()) { + const link = crazyGamesSDK.createInviteLink(this.lobbyId); + if (link !== null) { + return link; + } + } const config = await getServerConfigFromClient(); return `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}?lobby&s=${encodeURIComponent(this.lobbyUrlSuffix)}`; } @@ -123,10 +131,41 @@ export class HostLobbyModal extends BaseModal { } private updateHistory(url: string): void { - history.replaceState(null, "", url); + if (!crazyGamesSDK.isOnCrazyGames()) { + history.replaceState(null, "", url); + } } render() { + const maxTimerHandlers = this.createToggleHandlers( + () => this.maxTimer, + (val) => (this.maxTimer = val), + () => this.maxTimerValue, + (val) => (this.maxTimerValue = val), + 30, + ); + const spawnImmunityHandlers = this.createToggleHandlers( + () => this.spawnImmunity, + (val) => (this.spawnImmunity = val), + () => this.spawnImmunityDurationMinutes, + (val) => (this.spawnImmunityDurationMinutes = val), + 5, + ); + const goldMultiplierHandlers = this.createToggleHandlers( + () => this.goldMultiplier, + (val) => (this.goldMultiplier = val), + () => this.goldMultiplierValue, + (val) => (this.goldMultiplierValue = val), + 2, + ); + const startingGoldHandlers = this.createToggleHandlers( + () => this.startingGold, + (val) => (this.startingGold = val), + () => this.startingGoldValue, + (val) => (this.startingGoldValue = val), + 5000000, + ); + const content = html`
      -
      - - - -
      + `, })} @@ -256,80 +215,14 @@ export class HostLobbyModal extends BaseModal { ${translateText("map.map")}
      -
      - - ${Object.entries(mapCategories).map( - ([categoryKey, maps]) => html` -
      -

      - ${translateText(`map_categories.${categoryKey}`)} -

      -
      - ${maps.map((mapValue) => { - const mapKey = Object.entries(GameMapType).find( - ([, v]) => v === mapValue, - )?.[0]; - return html` -
      this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" - > - -
      - `; - })} -
      -
      - `, - )} - -
      -

      - ${translateText("map_categories.special")} -

      -
      - -
      -
      -
      + + this.handleMapSelection(mapValue)} + .onSelectRandom=${() => this.handleSelectRandomMap()} + >
      @@ -584,161 +477,79 @@ export class HostLobbyModal extends BaseModal { )} -
      this.maxTimer, - (val) => (this.maxTimer = val), - () => this.maxTimerValue, - (val) => (this.maxTimerValue = val), - 30, - ).click} - @keydown=${this.createToggleHandlers( - () => this.maxTimer, - (val) => (this.maxTimer = val), - () => this.maxTimerValue, - (val) => (this.maxTimerValue = val), - 30, - ).keydown} - class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this - .maxTimer - ? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]" - : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}" - > -
      -
      - ${this.maxTimer - ? html` - - ` - : ""} -
      -
      - - ${this.maxTimer - ? html` - e.stopPropagation()} - @input=${this.handleMaxTimerValueChanges} - @keydown=${this.handleMaxTimerValueKeyDown} - placeholder=${translateText( - "host_modal.mins_placeholder", - )} - /> - ` - : html`
      `} - -
      - ${translateText("host_modal.max_timer")} -
      -
      + ${renderToggleInputCard({ + labelKey: "host_modal.max_timer", + checked: this.maxTimer, + onClick: maxTimerHandlers.click, + input: renderToggleInputCardInput({ + min: 0, + max: 120, + value: this.maxTimerValue ?? 0, + ariaLabel: translateText("host_modal.max_timer"), + placeholder: translateText("host_modal.mins_placeholder"), + onInput: this.handleMaxTimerValueChanges, + onKeyDown: this.handleMaxTimerValueKeyDown, + }), + })} -
      this.spawnImmunity, - (val) => (this.spawnImmunity = val), - () => this.spawnImmunityDurationMinutes, - (val) => (this.spawnImmunityDurationMinutes = val), - 5, - ).click} - @keydown=${this.createToggleHandlers( - () => this.spawnImmunity, - (val) => (this.spawnImmunity = val), - () => this.spawnImmunityDurationMinutes, - (val) => (this.spawnImmunityDurationMinutes = val), - 5, - ).keydown} - class="relative p-3 rounded-xl border transition-all duration-200 flex flex-col items-center justify-between gap-2 h-full cursor-pointer min-h-[100px] ${this - .spawnImmunity - ? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]" - : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 opacity-80"}" - > -
      -
      - ${this.spawnImmunity - ? html` - - ` - : ""} -
      -
      + ${renderToggleInputCard({ + labelKey: "host_modal.player_immunity_duration", + checked: this.spawnImmunity, + onClick: spawnImmunityHandlers.click, + input: renderToggleInputCardInput({ + min: 0, + max: 120, + step: 1, + value: this.spawnImmunityDurationMinutes ?? 0, + ariaLabel: translateText( + "host_modal.player_immunity_duration", + ), + placeholder: translateText("host_modal.mins_placeholder"), + onInput: this.handleSpawnImmunityDurationInput, + onKeyDown: this.handleSpawnImmunityDurationKeyDown, + }), + })} - ${this.spawnImmunity - ? html` - e.stopPropagation()} - @input=${this.handleSpawnImmunityDurationInput} - @keydown=${this.handleSpawnImmunityDurationKeyDown} - placeholder=${translateText( - "host_modal.mins_placeholder", - )} - /> - ` - : html`
      `} + + ${renderToggleInputCard({ + labelKey: "single_modal.gold_multiplier", + checked: this.goldMultiplier, + onClick: goldMultiplierHandlers.click, + input: renderToggleInputCardInput({ + id: "gold-multiplier-value", + min: 0.1, + max: 1000, + step: "any", + value: this.goldMultiplierValue ?? "", + ariaLabel: translateText("single_modal.gold_multiplier"), + placeholder: translateText( + "single_modal.gold_multiplier_placeholder", + ), + onChange: this.handleGoldMultiplierValueChanges, + onKeyDown: this.handleGoldMultiplierValueKeyDown, + }), + })} -
      - ${translateText("host_modal.player_immunity_duration")} -
      -
      + + ${renderToggleInputCard({ + labelKey: "single_modal.starting_gold", + checked: this.startingGold, + onClick: startingGoldHandlers.click, + input: renderToggleInputCardInput({ + id: "starting-gold-value", + min: 0, + max: 1000000000, + step: 100000, + value: this.startingGoldValue ?? "", + ariaLabel: translateText("single_modal.starting_gold"), + placeholder: translateText( + "single_modal.starting_gold_placeholder", + ), + onInput: this.handleStartingGoldValueChanges, + onKeyDown: this.handleStartingGoldValueKeyDown, + }), + })}
      @@ -778,33 +589,17 @@ export class HostLobbyModal extends BaseModal {
      -
      -
      -
      - ${this.clients.length} - ${this.clients.length === 1 - ? translateText("host_modal.player") - : translateText("host_modal.players")} - - ${this.getEffectiveNationCount()} - ${this.getEffectiveNationCount() === 1 - ? translateText("host_modal.nation_player") - : translateText("host_modal.nation_players")} -
      -
      - - this.kickPlayer(clientID)} - > -
      + this.kickPlayer(clientID)} + >
@@ -841,10 +636,6 @@ export class HostLobbyModal extends BaseModal { protected onOpen(): void { this.lobbyCreatorClientID = generateID(); - this.lobbyIdVisible = this.userSettings.get( - "settings.lobbyIdVisibility", - true, - ); createLobby(this.lobbyCreatorClientID) .then(async (lobby) => { @@ -944,7 +735,7 @@ export class HostLobbyModal extends BaseModal { // Reset all transient form state to ensure clean slate this.selectedMap = GameMapType.World; - this.selectedDifficulty = Difficulty.Medium; + this.selectedDifficulty = Difficulty.Easy; this.disableNations = false; this.gameMode = GameMode.FFA; this.teamCount = 2; @@ -963,11 +754,13 @@ export class HostLobbyModal extends BaseModal { this.useRandomMap = false; this.disabledUnits = []; this.lobbyId = ""; - this.copySuccess = false; this.clients = []; this.lobbyCreatorClientID = ""; - this.lobbyIdVisible = true; this.nationCount = 0; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; this.leaveLobbyOnClose = true; } @@ -1036,6 +829,44 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + this.putGameConfig(); + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + this.putGameConfig(); + } + private handleRandomSpawnChange = (val: boolean) => { this.randomSpawn = val; this.putGameConfig(); @@ -1058,6 +889,11 @@ export class HostLobbyModal extends BaseModal { private handleCompactMapChange = (val: boolean) => { this.compactMap = val; + if (val && this.bots === 400) { + this.bots = 100; + } else if (!val && this.bots === 100) { + this.bots = 400; + } this.putGameConfig(); }; @@ -1146,6 +982,12 @@ export class HostLobbyModal extends BaseModal { }), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : undefined, + goldMultiplier: + this.goldMultiplier === true + ? this.goldMultiplierValue + : undefined, + startingGold: + this.startingGold === true ? this.startingGoldValue : undefined, } satisfies Partial, }, bubbles: true, @@ -1194,15 +1036,6 @@ export class HostLobbyModal extends BaseModal { return response; } - private async copyToClipboard() { - const url = await this.buildLobbyUrl(); - await copyToClipboard( - url, - () => (this.copySuccess = true), - () => (this.copySuccess = false), - ); - } - private async pollPlayers() { const config = await getServerConfigFromClient(); fetch(`/${config.workerPath(this.lobbyId)}/api/game/${this.lobbyId}`, { @@ -1229,31 +1062,22 @@ export class HostLobbyModal extends BaseModal { } private async loadNationCount() { + const currentMap = this.selectedMap; try { - const mapData = this.mapLoader.getMapData(this.selectedMap); + const mapData = this.mapLoader.getMapData(currentMap); const manifest = await mapData.manifest(); - this.nationCount = manifest.nations.length; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = manifest.nations.length; + } } catch (error) { console.warn("Failed to load nation count", error); - this.nationCount = 0; + // Only update if the map hasn't changed + if (this.selectedMap === currentMap) { + this.nationCount = 0; + } } } - - /** - * Returns the effective nation count for display purposes. - * In HumansVsNations mode, this equals the number of human players. - * For compact maps, only 25% of nations are used. - * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). - */ - private getEffectiveNationCount(): number { - if (this.disableNations) { - return 0; - } - if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { - return this.clients.length; - } - return getCompactMapNationCount(this.nationCount, this.compactMap); - } } async function createLobby(creatorClientID: string): Promise { diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index cb65b428f..9287b1730 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,6 +1,6 @@ import { html, TemplateResult } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import { copyToClipboard, translateText } from "../client/Utils"; +import { translateText } from "../client/Utils"; import { ClientInfo, GAME_ID_REGEX, @@ -10,13 +10,14 @@ import { } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { GameMode } from "../core/game/Game"; -import { UserSettings } from "../core/game/UserSettings"; +import { GameMapSize, GameMode } from "../core/game/Game"; import { getApiBase } from "./Api"; import { JoinLobbyEvent } from "./Main"; +import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; import "./components/Difficulties"; -import "./components/LobbyTeamView"; +import "./components/LobbyPlayerView"; import { modalHeader } from "./components/ui/ModalHeader"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends BaseModal { @@ -26,12 +27,12 @@ export class JoinPrivateLobbyModal extends BaseModal { @state() private players: ClientInfo[] = []; @state() private gameConfig: GameConfig | null = null; @state() private lobbyCreatorClientID: string | null = null; - @state() private lobbyIdVisible: boolean = true; - @state() private copySuccess: boolean = false; @state() private currentLobbyId: string = ""; + @state() private currentClientID: string = ""; + @state() private nationCount: number = 0; private playersInterval: NodeJS.Timeout | null = null; - private userSettings: UserSettings = new UserSettings(); + private mapLoader = terrainMapFileLoader; private leaveLobbyOnClose = true; @@ -50,91 +51,7 @@ export class JoinPrivateLobbyModal extends BaseModal { ariaLabel: translateText("common.close"), rightContent: this.hasJoined ? html` - -
- -
{ - (e.currentTarget as HTMLElement).classList.add( - "select-all", - ); - }} - @mouseleave=${(e: Event) => { - (e.currentTarget as HTMLElement).classList.remove( - "select-all", - ); - }} - class="font-mono text-xs font-bold text-white px-2 cursor-pointer select-none min-w-[80px] text-center truncate tracking-wider" - title="${translateText("common.click_to_copy")}" - > - ${this.copySuccess - ? translateText("common.copied") - : this.lobbyIdVisible - ? this.currentLobbyId - : "••••••••"} -
- -
+ ` : undefined, })} @@ -180,26 +97,18 @@ export class JoinPrivateLobbyModal extends BaseModal { ${this.renderGameConfig()} ${this.hasJoined && this.players.length > 0 ? html` -
-
-
- ${this.players.length} - ${this.players.length === 1 - ? translateText("private_lobby.player") - : translateText("private_lobby.players")} -
-
- - -
+ ` : ""}
@@ -347,10 +256,6 @@ export class JoinPrivateLobbyModal extends BaseModal { public open(id: string = "") { super.open(); - this.lobbyIdVisible = this.userSettings.get( - "settings.lobbyIdVisibility", - true, - ); if (id) { this.setLobbyId(id); this.joinLobby(); @@ -387,6 +292,8 @@ export class JoinPrivateLobbyModal extends BaseModal { this.hasJoined = false; this.message = ""; this.currentLobbyId = ""; + this.currentClientID = ""; + this.nationCount = 0; this.leaveLobbyOnClose = true; } @@ -396,15 +303,6 @@ export class JoinPrivateLobbyModal extends BaseModal { this.close(); } - private async copyToClipboard() { - const config = await getServerConfigFromClient(); - await copyToClipboard( - `${location.origin}/${config.workerPath(this.currentLobbyId)}/game/${this.currentLobbyId}`, - () => (this.copySuccess = true), - () => (this.copySuccess = false), - ); - } - private isValidLobbyId(value: string): boolean { return GAME_ID_REGEX.test(value); } @@ -523,6 +421,7 @@ export class JoinPrivateLobbyModal extends BaseModal { this.showMessage(translateText("private_lobby.joined_waiting")); this.message = ""; this.hasJoined = true; + this.currentClientID = generateID(); // If the modal closes as part of joining the game, do not leave the lobby this.leaveLobbyOnClose = false; @@ -531,7 +430,7 @@ export class JoinPrivateLobbyModal extends BaseModal { new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - clientID: generateID(), + clientID: this.currentClientID, } as JoinLobbyEvent, bubbles: true, composed: true, @@ -582,12 +481,13 @@ export class JoinPrivateLobbyModal extends BaseModal { return "version_mismatch"; } + this.currentClientID = generateID(); this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: lobbyId, gameRecord: parsed.data, - clientID: generateID(), + clientID: this.currentClientID, } as JoinLobbyEvent, bubbles: true, composed: true, @@ -612,11 +512,38 @@ export class JoinPrivateLobbyModal extends BaseModal { this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null; this.players = data.clients ?? []; if (data.gameConfig) { + const mapChanged = + this.gameConfig?.gameMap !== data.gameConfig.gameMap; this.gameConfig = data.gameConfig; + if (mapChanged) { + this.loadNationCount(); + } } }) .catch((error) => { console.error("Error polling players:", error); }); } + + private async loadNationCount() { + if (!this.gameConfig) { + this.nationCount = 0; + return; + } + const currentMap = this.gameConfig.gameMap; + try { + const mapData = this.mapLoader.getMapData(currentMap); + const manifest = await mapData.manifest(); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = manifest.nations.length; + } + } catch (error) { + console.warn("Failed to load nation count", error); + // Only update if the map hasn't changed + if (this.gameConfig?.gameMap === currentMap) { + this.nationCount = 0; + } + } + } } diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 1ee3329c7..f80415d06 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -228,6 +228,8 @@ export class LangSelector extends LitElement { "stats-modal", "flag-input-modal", "flag-input", + "matchmaking-button", + "token-login", ]; document.title = this.translateText("main.title") ?? document.title; diff --git a/src/client/LanguageModal.ts b/src/client/LanguageModal.ts index 9f6c09969..6d90c1d3e 100644 --- a/src/client/LanguageModal.ts +++ b/src/client/LanguageModal.ts @@ -75,13 +75,13 @@ export class LanguageModal extends BaseModal { />
${lang.native} ${lang.en}
diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 2514dc695..75121b38a 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -20,7 +20,13 @@ import { import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; -import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; +import { + defaultReplaySpeedMultiplier, + ReplaySpeedMultiplier, +} from "./utilities/ReplaySpeedMultiplier"; + +// build a small backlog so MAX can catch up. +const MAX_REPLAY_BACKLOG_TURNS = 60; export class LocalServer { // All turns from the game record on replay. @@ -64,9 +70,16 @@ export class LocalServer { const turnIntervalMs = this.lobbyConfig.serverConfig.turnIntervalMs() * this.replaySpeedMultiplier; + const backlog = Math.max(0, this.turns.length - this.turnsExecuted); + const allowReplayBacklog = + this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest && + this.lobbyConfig.gameRecord !== undefined; + const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0; + const canQueueNextTurn = + backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog); if ( - this.turnsExecuted === this.turns.length && + canQueueNextTurn && Date.now() > this.turnStartTime + turnIntervalMs ) { this.turnStartTime = Date.now(); diff --git a/src/client/Main.ts b/src/client/Main.ts index f2cdae39d..2b742e278 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -43,7 +43,7 @@ import { } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; import "./UsernameInput"; -import { UsernameInput } from "./UsernameInput"; +import { genAnonUsername, UsernameInput } from "./UsernameInput"; import { getDiscordAvatarUrl, incrementGamesPlayed, @@ -163,19 +163,12 @@ declare global { GIT_COMMIT: string; INSTANCE_ID: string; turnstile: any; - enableAds: boolean; + adsEnabled: boolean; PageOS: { session: { newPageView: () => void; }; }; - fusetag: { - registerZone: (id: string) => void; - destroyZone: (id: string) => void; - pageInit: (options?: any) => void; - que: Array<() => void>; - destroySticky: () => void; - }; ramp: { que: Array<() => void>; passiveMode: boolean; @@ -184,7 +177,7 @@ declare global { settings?: { slots?: any; }; - spaNewPage: (url: string) => void; + spaNewPage: (url?: string) => void; }; showPage?: (pageId: string) => void; } @@ -208,12 +201,15 @@ export interface JoinLobbyEvent { } class Client { - private gameStop: (() => void) | null = null; + private gameStop: ((force?: boolean) => boolean) | null = null; private eventBus: EventBus = new EventBus(); + private currentUrl: string | null = null; + private usernameInput: UsernameInput | null = null; private flagInput: FlagInput | null = null; + private hostModal: HostPrivateLobbyModal; private joinModal: JoinPrivateLobbyModal; private publicLobby: PublicLobby; private userSettings: UserSettings = new UserSettings(); @@ -277,7 +273,7 @@ class Client { window.addEventListener("beforeunload", async () => { console.log("Browser is closing"); if (this.gameStop !== null) { - this.gameStop(); + this.gameStop(true); await crazyGamesSDK.gameplayStop(); } }); @@ -431,57 +427,14 @@ class Client { ) { console.warn("Matchmaking modal element not found"); } - const matchmakingButton = document.getElementById("matchmaking-button"); - const matchmakingButtonLoggedOut = document.getElementById( - "matchmaking-button-logged-out", - ); - - const updateMatchmakingButton = (loggedIn: boolean) => { - if (!loggedIn) { - matchmakingButton?.classList.add("hidden"); - matchmakingButtonLoggedOut?.classList.remove("hidden"); - } else { - matchmakingButton?.classList.remove("hidden"); - matchmakingButtonLoggedOut?.classList.add("hidden"); - } - }; - - if (matchmakingButton) { - matchmakingButton.addEventListener("click", () => { - if (this.usernameInput?.isValid()) { - window.showPage?.("page-matchmaking"); - this.publicLobby.leaveLobby(); - } else { - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { - message: this.usernameInput?.validationError, - color: "red", - duration: 3000, - }, - }), - ); - } - }); - } - - if (matchmakingButtonLoggedOut) { - matchmakingButtonLoggedOut.addEventListener("click", () => { - window.showPage?.("page-account"); - }); - } const onUserMe = async (userMeResponse: UserMeResponse | false) => { - // Check if user has actual authentication (discord or email), not just a publicId - const loggedIn = - userMeResponse !== false && - userMeResponse !== null && - typeof userMeResponse === "object" && - userMeResponse.user && - (userMeResponse.user.discord !== undefined || - userMeResponse.user.email !== undefined); - updateMatchmakingButton(loggedIn); updateAccountNavButton(userMeResponse); + const hasLinkedAccount = + !crazyGamesSDK.isOnCrazyGames() && + ((userMeResponse || null)?.player?.flares?.length ?? 0) > 0; + console.log("ads enabled: ", hasLinkedAccount); + window.adsEnabled = !hasLinkedAccount && !crazyGamesSDK.isOnCrazyGames(); document.dispatchEvent( new CustomEvent("userMeResponse", { detail: userMeResponse, @@ -522,10 +475,10 @@ class Client { } }); - const hostModal = document.querySelector( + this.hostModal = document.querySelector( "host-lobby-modal", ) as HostPrivateLobbyModal; - if (!hostModal || !(hostModal instanceof HostPrivateLobbyModal)) { + if (!this.hostModal || !(this.hostModal instanceof HostPrivateLobbyModal)) { console.warn("Host private lobby modal element not found"); } const hostLobbyButton = document.getElementById("host-lobby-button"); @@ -581,19 +534,51 @@ class Client { } // Attempt to join lobby - this.handleUrl(); - - let preventHashUpdate = false; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => this.handleUrl()); + } else { + this.handleUrl(); + } const onHashUpdate = () => { - // Prevent double-handling when both popstate and hashchange fire - if (preventHashUpdate) { - preventHashUpdate = false; - return; - } - // Reset the UI to its initial state this.joinModal?.close(); + + onJoinChanged(); + }; + + const onPopState = () => { + if (this.currentUrl !== null && this.gameStop !== null) { + console.info("Game is active"); + + if (!this.gameStop()) { + console.info("Player is active, ask before leaving game"); + + const isConfirmed = confirm( + translateText("help_modal.exit_confirmation"), + ); + + if (!isConfirmed) { + // Rollback navigator history + history.pushState(null, "", this.currentUrl); + return; + } + } + + console.info("Player is not active, leave the game immediately"); + + crazyGamesSDK.gameplayStop().then(() => { + // redirect to the home page + window.location.href = "/"; + }); + } else { + console.info("Game not active, handle hash update"); + + onHashUpdate(); + } + }; + + const onJoinChanged = () => { if (this.gameStop !== null) { this.handleLeaveLobby(); } @@ -603,12 +588,9 @@ class Client { }; // Handle browser navigation & manual hash edits - window.addEventListener("popstate", () => { - preventHashUpdate = true; - onHashUpdate(); - }); + window.addEventListener("popstate", onPopState); window.addEventListener("hashchange", onHashUpdate); - window.addEventListener("join-changed", onHashUpdate); + window.addEventListener("join-changed", onJoinChanged); function updateSliderProgress(slider: HTMLInputElement) { const percent = @@ -626,21 +608,38 @@ class Client { updateSliderProgress(slider); slider.addEventListener("input", () => updateSliderProgress(slider)); }); - - this.initializeFuseTag(); } - private handleUrl() { + private async handleUrl() { + // Wait for modal custom elements to be defined + await Promise.all([ + customElements.whenDefined("join-private-lobby-modal"), + customElements.whenDefined("host-lobby-modal"), + ]); + // Check if CrazyGames SDK is enabled first (no hash needed in CrazyGames) if (crazyGamesSDK.isOnCrazyGames()) { - const lobbyId = crazyGamesSDK.getInviteGameId(); + const lobbyId = await crazyGamesSDK.getInviteGameId(); + console.log("got game id", lobbyId); if (lobbyId && GAME_ID_REGEX.test(lobbyId)) { + console.log("game parsed successfully"); + // Wait 2 seconds to ensure all elements are actually loaded, + // On low end-chromebooks the join modal was not registered in time. + await new Promise((resolve) => setTimeout(resolve, 2000)); window.showPage?.("page-join-private-lobby"); this.joinModal?.open(lobbyId); console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`); return; } } + crazyGamesSDK.isInstantMultiplayer().then((isInstant) => { + if (isInstant) { + console.log( + `CrazyGames: joining instant multiplayer lobby from CrazyGames`, + ); + this.hostModal.open(); + } + }); const strip = () => history.replaceState( @@ -687,7 +686,7 @@ class Client { // in case it is unset during reload. this.userSettings.setSelectedPatternName(patternName); }); - this.tokenLoginModal.open(token); + this.tokenLoginModal.openWithToken(token); } else { alertAndStrip(`purchase succeeded: ${patternName}`); this.patternsModal.refresh(); @@ -706,7 +705,7 @@ class Client { } strip(); - this.tokenLoginModal.open(token); + this.tokenLoginModal.openWithToken(token); return; } @@ -738,7 +737,7 @@ class Client { console.log(`joining lobby ${lobby.gameID}`); if (this.gameStop !== null) { console.log("joining lobby, stopping existing game"); - this.gameStop(); + this.gameStop(true); document.body.classList.remove("in-game"); } const config = await getServerConfigFromClient(); @@ -763,7 +762,8 @@ class Client { : this.flagInput.getCurrentFlag(), }, turnstileToken: await this.getTurnstileToken(lobby), - playerName: this.usernameInput?.getCurrentUsername() ?? "", + playerName: + this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), clientID: lobby.clientID, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, @@ -786,6 +786,7 @@ class Client { "game-top-bar", "help-modal", "user-setting", + "troubleshooting-modal", "territory-patterns-modal", "language-modal", "news-modal", @@ -820,7 +821,6 @@ class Client { if (startingModal && startingModal instanceof GameStartingModal) { startingModal.show(); } - this.gutterAds.hide(); }, () => { this.joinModal.close(); @@ -831,6 +831,9 @@ class Client { (ad as HTMLElement).style.display = "none"; }); + if (window.PageOS?.session?.newPageView) { + window.PageOS.session.newPageView(); + } crazyGamesSDK.loadingStop(); crazyGamesSDK.gameplayStart(); document.body.classList.add("in-game"); @@ -844,6 +847,9 @@ class Client { "", `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`, ); + + // Store current URL for popstate confirmation + this.currentUrl = window.location.href; }, ); } @@ -865,14 +871,19 @@ class Client { return; } console.log("leaving lobby, cancelling game"); - this.gameStop(); + this.gameStop(true); this.gameStop = null; + this.currentUrl = null; + + try { + history.replaceState(null, "", "/"); + } catch (e) { + console.warn("Failed to restore URL on leave:", e); + } document.body.classList.remove("in-game"); crazyGamesSDK.gameplayStop(); - - this.gutterAds.hide(); this.publicLobby.leaveLobby(); } @@ -894,28 +905,6 @@ class Client { } } - private initializeFuseTag() { - const tryInitFuseTag = (): boolean => { - if (window.fusetag && typeof window.fusetag.pageInit === "function") { - console.log("initializing fuse tag"); - window.fusetag.que.push(() => { - window.fusetag.pageInit({ - blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"], - }); - }); - return true; - } else { - return false; - } - }; - - const interval = setInterval(() => { - if (tryInitFuseTag()) { - clearInterval(interval); - } - }, 100); - } - private async getTurnstileToken( lobby: JoinLobbyEvent, ): Promise { @@ -927,7 +916,8 @@ class Client { return null; } - if (this.turnstileTokenPromise === null) { + // Always request a new token on crazygames. + if (this.turnstileTokenPromise === null || crazyGamesSDK.isOnCrazyGames()) { console.log("No prefetched turnstile token, getting new token"); return (await getTurnstileToken())?.token ?? null; } @@ -943,6 +933,7 @@ class Client { const tokenTTL = 3 * 60 * 1000; if (Date.now() < token.createdAt + tokenTTL) { console.log("Prefetched turnstile token is valid"); + return token.token; } else { console.log("Turnstile token expired, getting new token"); @@ -951,11 +942,27 @@ class Client { } } +// Hide elements with no-crazygames class if on CrazyGames +const hideCrazyGamesElements = () => { + if (crazyGamesSDK.isOnCrazyGames()) { + document.querySelectorAll(".no-crazygames").forEach((el) => { + (el as HTMLElement).style.display = "none"; + }); + } +}; + // Initialize the client when the DOM is loaded const bootstrap = () => { initLayout(); new Client().initialize(); initNavigation(); + + // Hide elements immediately + hideCrazyGamesElements(); + + // Also hide elements after a short delay to catch late-rendered components + setTimeout(hideCrazyGamesElements, 100); + setTimeout(hideCrazyGamesElements, 500); }; if (document.readyState === "loading") { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 73307d15d..e495ef131 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -3,7 +3,7 @@ import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { generateID } from "../core/Util"; -import { getUserMe } from "./Api"; +import { getUserMe, hasLinkedAccount } from "./Api"; import { getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; @@ -240,6 +240,7 @@ export class MatchmakingModal extends BaseModal { @customElement("matchmaking-button") export class MatchmakingButton extends LitElement { @query("matchmaking-modal") private matchmakingModal?: MatchmakingModal; + @state() private isLoggedIn = false; constructor() { super(); @@ -247,6 +248,14 @@ export class MatchmakingButton extends LitElement { async connectedCallback() { super.connectedCallback(); + // Listen for user authentication changes + document.addEventListener("userMeResponse", (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail) { + const userMeResponse = customEvent.detail as UserMeResponse | false; + this.isLoggedIn = hasLinkedAccount(userMeResponse); + } + }); } createRenderRoot() { @@ -254,17 +263,59 @@ export class MatchmakingButton extends LitElement { } render() { - return html` -
- -
- - `; + const button = this.isLoggedIn + ? html` + + ` + : html` + + `; + + return html` ${button} `; + } + + private handleLoggedInClick() { + const usernameInput = document.querySelector("username-input") as any; + const publicLobby = document.querySelector("public-lobby") as any; + + if (usernameInput?.isValid()) { + this.open(); + publicLobby?.leaveLobby(); + } else { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: usernameInput?.validationError, + color: "red", + duration: 3000, + }, + }), + ); + } + } + + private handleLoggedOutClick() { + window.showPage?.("page-account"); } private open() { diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 0a150144f..fe0d622f3 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -28,12 +28,12 @@ export class NewsModal extends BaseModal { ariaLabel: translateText("common.back"), })}
(response.ok ? response.text() : "Failed to load")) .then((markdown) => markdown diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 7c435c63c..483bef75c 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -5,22 +5,9 @@ import { UserSettings } from "../core/game/UserSettings"; import { PlayerPattern } from "../core/Schemas"; import { renderPatternPreview } from "./components/PatternButton"; import { fetchCosmetics } from "./Cosmetics"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { translateText } from "./Utils"; -// Module-level cosmetics cache to avoid refetching on every component mount -let cosmeticsCache: Promise | null = null; - -function getCachedCosmetics(): Promise { - if (!cosmeticsCache) { - const fetchPromise = fetchCosmetics(); - cosmeticsCache = fetchPromise.catch((err) => { - cosmeticsCache = null; - throw err; - }); - } - return cosmeticsCache; -} - @customElement("pattern-input") export class PatternInput extends LitElement { @state() public pattern: PlayerPattern | null = null; @@ -63,7 +50,7 @@ export class PatternInput extends LitElement { super.connectedCallback(); this._abortController = new AbortController(); this.isLoading = true; - const cosmetics = await getCachedCosmetics(); + const cosmetics = await fetchCosmetics(); if (!this.isConnected) return; this.cosmetics = cosmetics; this.updateFromSettings(); @@ -87,6 +74,10 @@ export class PatternInput extends LitElement { } render() { + if (crazyGamesSDK.isOnCrazyGames()) { + return html``; + } + const isDefault = this.pattern === null && this.selectedColor === null; const showSelect = this.showSelectLabel && isDefault; const buttonTitle = translateText("territory_patterns.title"); diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index c7516804d..e7610672e 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -374,6 +374,12 @@ export class PublicLobby extends LitElement { if (publicGameModifiers.isCompact) { labels.push(translateText("public_game_modifier.compact_map")); } + if (publicGameModifiers.isCrowded) { + labels.push(translateText("public_game_modifier.crowded")); + } + if (publicGameModifiers.startingGold) { + labels.push(translateText("public_game_modifier.starting_gold")); + } return labels; } diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 91cec73fc..1db98cf31 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -13,7 +13,6 @@ import { Quads, Trios, UnitType, - mapCategories, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import { TeamCountConfig } from "../core/Schemas"; @@ -24,19 +23,23 @@ import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/FluentSlider"; -import "./components/Maps"; +import "./components/map/MapPicker"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; +import { + renderToggleInputCard, + renderToggleInputCardInput, +} from "./utilities/RenderToggleInputCard"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; -import randomMap from "/images/RandomMap.webp?url"; @customElement("single-player-modal") export class SinglePlayerModal extends BaseModal { @state() private selectedMap: GameMapType = GameMapType.World; - @state() private selectedDifficulty: Difficulty = Difficulty.Medium; + @state() private selectedDifficulty: Difficulty = Difficulty.Easy; @state() private disableNations: boolean = false; @state() private bots: number = 400; @state() private infiniteGold: boolean = false; @@ -52,6 +55,10 @@ export class SinglePlayerModal extends BaseModal { @state() private showAchievements: boolean = false; @state() private mapWins: Map> = new Map(); @state() private userMeResponse: UserMeResponse | false = false; + @state() private goldMultiplier: boolean = false; + @state() private goldMultiplierValue: number | undefined = undefined; + @state() private startingGold: boolean = false; + @state() private startingGoldValue: number | undefined = undefined; @state() private disabledUnits: UnitType[] = []; @@ -85,6 +92,9 @@ export class SinglePlayerModal extends BaseModal { }; private renderNotLoggedInBanner(): TemplateResult { + if (crazyGamesSDK.isOnCrazyGames()) { + return html``; + } return html`
@@ -189,84 +199,15 @@ export class SinglePlayerModal extends BaseModal {
-
- ${Object.entries(mapCategories).map( - ([categoryKey, maps]) => html` -
-

- ${translateText(`map_categories.${categoryKey}`)} -

-
- ${maps.map((mapValue) => { - const mapKey = Object.keys(GameMapType).find( - (key) => - GameMapType[key as keyof typeof GameMapType] === - mapValue, - ); - return html` -
this.handleMapSelection(mapValue)} - class="cursor-pointer transition-transform duration-200 active:scale-95" - > - -
- `; - })} -
-
- `, - )} - - -
-

- ${translateText("map_categories.special")} -

-
- -
-
-
+ + this.handleMapSelection(mapValue)} + .onSelectRandom=${() => this.handleSelectRandomMap()} + >
@@ -509,22 +450,19 @@ export class SinglePlayerModal extends BaseModal { ${this.renderOptionToggle( "single_modal.compact_map", this.compactMap, - (val) => (this.compactMap = val), + (val) => { + this.compactMap = val; + if (val && this.bots === 400) { + this.bots = 100; + } else if (!val && this.bots === 100) { + this.bots = 400; + } + }, )} - - -
{ - // Prevent toggling when clicking the input - if ( - (e.target as HTMLElement).tagName.toLowerCase() === - "input" - ) - return; + ${renderToggleInputCard({ + labelKey: "single_modal.max_timer", + checked: this.maxTimer, + onClick: () => { this.maxTimer = !this.maxTimer; if (!this.maxTimer) { this.maxTimerValue = undefined; @@ -542,58 +480,102 @@ export class SinglePlayerModal extends BaseModal { } }, 0); } - }} - > -
-
- ${this.maxTimer - ? html` - - ` - : ""} -
-
+ }, + input: renderToggleInputCardInput({ + id: "end-timer-value", + min: 1, + max: 120, + value: this.maxTimerValue ?? "", + ariaLabel: translateText("single_modal.max_timer"), + placeholder: translateText( + "single_modal.max_timer_placeholder", + ), + onInput: this.handleMaxTimerValueChanges, + onKeyDown: this.handleMaxTimerValueKeyDown, + }), + })} - ${this.maxTimer - ? html`` - : html`
`} - + + ${renderToggleInputCard({ + labelKey: "single_modal.gold_multiplier", + checked: this.goldMultiplier, + onClick: () => { + this.goldMultiplier = !this.goldMultiplier; + if (!this.goldMultiplier) { + this.goldMultiplierValue = undefined; + } else { + if ( + !this.goldMultiplierValue || + this.goldMultiplierValue <= 0 + ) { + this.goldMultiplierValue = 2; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#gold-multiplier-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }, + input: renderToggleInputCardInput({ + id: "gold-multiplier-value", + min: 0.1, + max: 1000, + step: "any", + value: this.goldMultiplierValue ?? "", + ariaLabel: translateText("single_modal.gold_multiplier"), + placeholder: translateText( + "single_modal.gold_multiplier_placeholder", + ), + onChange: this.handleGoldMultiplierValueChanges, + onKeyDown: this.handleGoldMultiplierValueKeyDown, + }), + })} -
- ${translateText("single_modal.max_timer")} -
-
+ + ${renderToggleInputCard({ + labelKey: "single_modal.starting_gold", + checked: this.startingGold, + onClick: () => { + this.startingGold = !this.startingGold; + if (!this.startingGold) { + this.startingGoldValue = undefined; + } else { + if ( + !this.startingGoldValue || + this.startingGoldValue < 0 + ) { + this.startingGoldValue = 5000000; + } + setTimeout(() => { + const input = this.renderRoot.querySelector( + "#starting-gold-value", + ) as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } + }, + input: renderToggleInputCardInput({ + id: "starting-gold-value", + min: 0, + max: 1000000000, + step: 100000, + value: this.startingGoldValue ?? "", + ariaLabel: translateText("single_modal.starting_gold"), + placeholder: translateText( + "single_modal.starting_gold_placeholder", + ), + onInput: this.handleStartingGoldValueChanges, + onKeyDown: this.handleStartingGoldValueKeyDown, + }), + })}
@@ -693,7 +675,7 @@ export class SinglePlayerModal extends BaseModal { protected onClose(): void { // Reset all transient form state to ensure clean slate this.selectedMap = GameMapType.World; - this.selectedDifficulty = Difficulty.Medium; + this.selectedDifficulty = Difficulty.Easy; this.gameMode = GameMode.FFA; this.useRandomMap = false; this.disableNations = false; @@ -707,6 +689,10 @@ export class SinglePlayerModal extends BaseModal { this.randomSpawn = false; this.teamCount = 2; this.disabledUnits = []; + this.goldMultiplier = false; + this.goldMultiplierValue = undefined; + this.startingGold = false; + this.startingGoldValue = undefined; } private handleSelectRandomMap() { @@ -760,6 +746,42 @@ export class SinglePlayerModal extends BaseModal { } } + private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) { + if (["+", "-", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleGoldMultiplierValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + const value = parseFloat(input.value); + + if (isNaN(value) || value < 0.1 || value > 1000) { + this.goldMultiplierValue = undefined; + input.value = ""; + } else { + this.goldMultiplierValue = value; + } + } + + private handleStartingGoldValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleStartingGoldValueChanges(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value); + + if (isNaN(value) || value < 0 || value > 1000000000) { + this.startingGoldValue = undefined; + } else { + this.startingGoldValue = value; + } + } + private handleGameModeSelection(value: GameMode) { this.gameMode = value; } @@ -832,6 +854,8 @@ export class SinglePlayerModal extends BaseModal { const selectedColor = this.userSettings.getSelectedColor(); + await crazyGamesSDK.requestMidgameAd(); + this.dispatchEvent( new CustomEvent("join-lobby", { detail: { @@ -881,6 +905,12 @@ export class SinglePlayerModal extends BaseModal { : { disableNations: this.disableNations, }), + ...(this.goldMultiplier && this.goldMultiplierValue + ? { goldMultiplier: this.goldMultiplierValue } + : {}), + ...(this.startingGold && this.startingGoldValue !== undefined + ? { startingGold: this.startingGoldValue } + : {}), }, lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, diff --git a/src/client/StatsModal.ts b/src/client/StatsModal.ts index 1346b0031..b2fc64257 100644 --- a/src/client/StatsModal.ts +++ b/src/client/StatsModal.ts @@ -196,7 +196,7 @@ export class StatsModal extends BaseModal { const maxGames = Math.max(...clans.map((c) => c.games), 1); return html` -
+
diff --git a/src/client/TerrainMapFileLoader.ts b/src/client/TerrainMapFileLoader.ts index 771698902..c3b185ac9 100644 --- a/src/client/TerrainMapFileLoader.ts +++ b/src/client/TerrainMapFileLoader.ts @@ -1,4 +1,6 @@ -import version from "resources/version.txt?raw"; import { FetchGameMapLoader } from "../core/game/FetchGameMapLoader"; -export const terrainMapFileLoader = new FetchGameMapLoader(`/maps`, version); +export const terrainMapFileLoader = new FetchGameMapLoader( + `/maps`, + window.GIT_COMMIT, +); diff --git a/src/client/TokenLoginModal.ts b/src/client/TokenLoginModal.ts index cb7ef143e..97f0b3043 100644 --- a/src/client/TokenLoginModal.ts +++ b/src/client/TokenLoginModal.ts @@ -1,17 +1,14 @@ -import { html, LitElement } from "lit"; -import { customElement, query } from "lit/decorators.js"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; import { tempTokenLogin } from "./Auth"; +import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import "./components/PatternButton"; +import { modalHeader } from "./components/ui/ModalHeader"; import { translateText } from "./Utils"; @customElement("token-login") -export class TokenLoginModal extends LitElement { - @query("o-modal") private modalEl!: HTMLElement & { - open: () => void; - close: () => void; - }; - +export class TokenLoginModal extends BaseModal { private isAttemptingLogin = false; private retryInterval: NodeJS.Timeout | undefined = undefined; @@ -27,39 +24,98 @@ export class TokenLoginModal extends LitElement { } render() { + const title = translateText("token_login_modal.title"); + const content = html` +
+ ${modalHeader({ + title, + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + })} +
+ ${this.email ? this.loginSuccess(this.email) : this.loggingIn()} +
+
+ `; + + if (this.inline) { + return content; + } + return html` - ${this.email ? this.loginSuccess(this.email) : this.loggingIn()} + ${content} `; } private loggingIn() { - return html`

${translateText("token_login_modal.logging_in")}

`; + const loggingText = translateText("token_login_modal.logging_in"); + return html` +
+
+
+
+
+

${loggingText}

+
+
+
+
+
+ `; } private loginSuccess(email: string) { - return html`

- ${translateText("token_login_modal.success", { - email, - })} -

`; + const successText = translateText("token_login_modal.success", { email }); + return html` +
+
+
+
+

${successText}

+
+ `; } - public async open(token: string) { - this.token = token; - this.modalEl?.open(); + public open(): void { + if (!this.token) { + return; + } + super.open(); + clearInterval(this.retryInterval); this.retryInterval = setInterval(() => this.tryLogin(), 3000); } + public openWithToken(token: string): void { + this.token = token; + this.email = null; + this.attemptCount = 0; + this.isAttemptingLogin = false; + this.open(); + } + public close() { this.token = null; clearInterval(this.retryInterval); this.attemptCount = 0; - this.modalEl?.close(); + super.close(); this.isAttemptingLogin = false; } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 57f557773..95db9b589 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -82,10 +82,8 @@ export class SendAttackIntentEvent implements GameEvent { export class SendBoatAttackIntentEvent implements GameEvent { constructor( - public readonly targetID: PlayerID | null, public readonly dst: TileRef, public readonly troops: number, - public readonly src: TileRef | null = null, ) {} } @@ -500,10 +498,8 @@ export class Transport { this.sendIntent({ type: "boat", clientID: this.lobbyConfig.clientID, - targetID: event.targetID, troops: event.troops, dst: event.dst, - src: event.src, }); } @@ -727,10 +723,18 @@ export class Transport { this.socket.onclose = null; this.socket.onerror = null; - // Close the connection if it's still open - if (this.socket.readyState === WebSocket.OPEN) { - this.socket.close(); + // Close the connection if it's still open or still connecting + try { + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { + this.socket.close(); + } + } catch (e) { + console.warn("Error while closing WebSocket:", e); } + this.socket = null; } } diff --git a/src/client/TroubleshootingModal.ts b/src/client/TroubleshootingModal.ts new file mode 100644 index 000000000..4a017a4fe --- /dev/null +++ b/src/client/TroubleshootingModal.ts @@ -0,0 +1,254 @@ +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "./Utils"; +import { BaseModal } from "./components/BaseModal"; +import "./components/baseComponents/Modal"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { + collectGraphicsDiagnostics, + GraphicsDiagnostics, +} from "./utilities/Diagnostic"; +import infoIcon from "/images/InfoIcon.svg?url"; + +@customElement("troubleshooting-modal") +export class TroubleshootingModal extends BaseModal { + @property({ type: String }) markdown = "Loading..."; + + @property({ type: Object }) + diagnostics?: GraphicsDiagnostics; + + @property({ type: Boolean }) loading = true; + + private initialized: boolean = false; + + private async loadDiagnostics() { + const canvas = document.createElement("canvas"); + this.diagnostics = await collectGraphicsDiagnostics(canvas); + this.loading = false; + this.initialized = true; + } + + render() { + const content = html` +
+ ${modalHeader({ + titleContent: html`
+ + ${translateText("main.help")} + / ${translateText("troubleshooting.title")} + + +
`, + onBack: this.close, + ariaLabel: translateText("common.back"), + })} + ${this.loading + ? "" + : html` +
+ ${this.section( + "", + html`${this.infoTip( + translateText("troubleshooting.hardware_acceleration_tip"), + true, + )}`, + )} + ${this.section( + translateText("troubleshooting.environment"), + html` + ${this.row( + translateText("troubleshooting.browser"), + this.diagnostics!.browser.engine, + )} + ${this.row( + translateText("troubleshooting.platform"), + this.diagnostics!.browser.platform, + )} + ${this.row( + translateText("troubleshooting.os"), + this.diagnostics!.browser.os, + )} + ${this.row( + translateText("troubleshooting.device_pixel_ratio"), + this.diagnostics!.browser.dpr, + )} + ${this.infoTip( + translateText("troubleshooting.chromium_tip"), + )} + `, + )} + ${this.section( + translateText("troubleshooting.rendering"), + html` + ${this.row( + translateText("troubleshooting.renderer"), + this.describeRenderer(this.diagnostics!.rendering), + )} + ${this.row( + translateText("troubleshooting.max_texture_size"), + this.diagnostics!.rendering.maxTextureSize ?? + translateText("troubleshooting.unknown"), + )} + ${this.row( + translateText("troubleshooting.high_precision_shaders"), + this.diagnostics!.rendering.shaderHighp === true + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )}${this.row( + translateText("troubleshooting.gpu"), + !this.diagnostics!.rendering.gpu || + this.diagnostics!.rendering.gpu.unavailable + ? translateText("troubleshooting.unavailable") + : `${this.diagnostics!.rendering.gpu.vendor} — ${this.diagnostics!.rendering.gpu.renderer}`, + )} + ${this.infoTip(translateText("troubleshooting.gpu_tip"))} + `, + )} + ${this.section( + translateText("troubleshooting.power"), + html` + ${this.diagnostics!.power.unavailable + ? this.row( + translateText("troubleshooting.battery"), + translateText("troubleshooting.unavailable"), + ) + : html` + ${this.row( + translateText("troubleshooting.charging"), + this.diagnostics!.power.charging + ? translateText("troubleshooting.yes") + : translateText("troubleshooting.no"), + )} + ${this.row( + translateText("troubleshooting.battery_level"), + this.diagnostics!.power.level, + )} + `} + ${this.infoTip( + translateText("troubleshooting.power_saving_tip"), + )} + `, + )} +
+ `} +
+ `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } + + private infoTip(text: string, warning?: boolean): unknown { + return html` +
+ + ${text} +
+ `; + } + + protected onOpen(): void { + if (!this.initialized) { + this.initialized = true; + this.loadDiagnostics(); + } + } + + private section(title: string, content: unknown) { + return html` +
+

+ ${title} +

+
${content}
+
+ `; + } + + private row(label: string, value: unknown) { + return html` +
+ ${label} + ${value} +
+ `; + } + + private async copyDiagnostics() { + if (!this.diagnostics) return; + const formatted = + "```json\n" + JSON.stringify(this.diagnostics, null, 2) + "\n```"; + await navigator.clipboard.writeText(formatted); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: html`${translateText("troubleshooting.copied_to_clipboard")}`, + type: "info", + duration: 3000, + }, + }), + ); + } + + private describeRenderer(rendering: any): string { + if (rendering.gpu?.software) { + return translateText("troubleshooting.software_rendering"); + } + if (rendering.type === "Canvas2D") { + return translateText("troubleshooting.canvas_2d_no_gpu"); + } + return `${rendering.type}`; + } + + public close(): void { + this.unregisterEscapeHandler(); + + if (this.inline) { + this.style.pointerEvents = "none"; + if (window.showPage) { + window.showPage?.("page-help"); + } + } else { + this.modalEl?.close(); + } + } +} diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 9074b11e3..e80e94d74 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -431,7 +431,7 @@ export class UserSettingModal extends BaseModal {
${activeContent}
diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 20b2bb372..cfff06a1f 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -8,6 +8,7 @@ import { MIN_USERNAME_LENGTH, validateUsername, } from "../core/validations/username"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; const usernameKey: string = "username"; @@ -39,8 +40,20 @@ export class UsernameInput extends LitElement { connectedCallback() { super.connectedCallback(); - const stored = this.getStoredUsername(); + const stored = this.getUsername(); this.parseAndSetUsername(stored); + crazyGamesSDK.getUsername().then((username) => { + if (username) { + this.parseAndSetUsername(username ?? genAnonUsername()); + this.requestUpdate(); + } + }); + crazyGamesSDK.addAuthListener((user) => { + if (user) { + this.parseAndSetUsername(user?.username); + } + this.requestUpdate(); + }); } private parseAndSetUsername(fullUsername: string) { @@ -52,6 +65,8 @@ export class UsernameInput extends LitElement { this.clanTag = ""; this.baseUsername = fullUsername; } + + this.validateAndStore(); } render() { @@ -161,7 +176,7 @@ export class UsernameInput extends LitElement { } } - private getStoredUsername(): string { + private getUsername(): string { const storedUsername = localStorage.getItem(usernameKey); if (storedUsername) { return storedUsername; @@ -176,20 +191,20 @@ export class UsernameInput extends LitElement { } private generateNewUsername(): string { - const newUsername = "Anon" + this.uuidToThreeDigits(); + const newUsername = genAnonUsername(); this.storeUsername(newUsername); return newUsername; } - private uuidToThreeDigits(): string { - const uuid = uuidv4(); - const cleanUuid = uuid.replace(/-/g, "").toLowerCase(); - const decimal = BigInt(`0x${cleanUuid}`); - const threeDigits = decimal % 1000n; - return threeDigits.toString().padStart(3, "0"); - } - public isValid(): boolean { return this._isValid; } } + +export function genAnonUsername(): string { + const uuid = uuidv4(); + const cleanUuid = uuid.replace(/-/g, "").toLowerCase(); + const decimal = BigInt(`0x${cleanUuid}`); + const threeDigits = decimal % 1000n; + return "Anon" + threeDigits.toString().padStart(3, "0"); +} diff --git a/src/client/components/CopyButton.ts b/src/client/components/CopyButton.ts new file mode 100644 index 000000000..5a7b7fbec --- /dev/null +++ b/src/client/components/CopyButton.ts @@ -0,0 +1,213 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { getServerConfigFromClient } from "../../core/configuration/ConfigLoader"; +import { UserSettings } from "../../core/game/UserSettings"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; +import { copyToClipboard, translateText } from "../Utils"; + +@customElement("copy-button") +export class CopyButton extends LitElement { + @property({ type: String, attribute: "lobby-id" }) lobbyId = ""; + @property({ type: String, attribute: "lobby-suffix" }) lobbySuffix = ""; + @property({ type: Boolean, attribute: "include-lobby-query" }) + includeLobbyQuery = false; + @property({ type: String, attribute: "copy-text" }) copyText = ""; + @property({ type: String, attribute: "display-text" }) displayText = ""; + @property({ type: Boolean, attribute: "show-visibility-toggle" }) + showVisibilityToggle = true; + @property({ type: Boolean, attribute: "show-copy-icon" }) + showCopyIcon = true; + @property({ type: Boolean }) compact = false; + + @state() private copySuccess = false; + @state() private lobbyIdVisible = true; + + private userSettings: UserSettings = new UserSettings(); + private maskLabel = html`••••••••`; + + createRenderRoot() { + return this; + } + + protected willUpdate( + changedProperties: Map, + ) { + if (changedProperties.has("lobbyId")) { + this.lobbyIdVisible = this.userSettings.get( + "settings.lobbyIdVisibility", + true, + ); + this.copySuccess = false; + } + if (changedProperties.has("copyText")) { + this.copySuccess = false; + } + if ( + changedProperties.has("showVisibilityToggle") || + changedProperties.has("compact") + ) { + if (!this.showVisibilityToggle || this.compact) { + this.lobbyIdVisible = true; + } + } + } + + private toggleVisibility() { + if (!this.showVisibilityToggle || this.compact) return; + this.lobbyIdVisible = !this.lobbyIdVisible; + } + + private enableSelectAll(e: Event) { + (e.currentTarget as HTMLElement).classList.add("select-all"); + } + + private clearSelectAll(e: Event) { + (e.currentTarget as HTMLElement).classList.remove("select-all"); + } + + private async buildCopyUrl(): Promise { + const config = await getServerConfigFromClient(); + let url = `${window.location.origin}/${config.workerPath(this.lobbyId)}/game/${this.lobbyId}`; + if (this.includeLobbyQuery) { + url += `?lobby&s=${encodeURIComponent(this.lobbySuffix)}`; + } + return url; + } + + private async resolveCopyText(): Promise { + if (this.copyText) return this.copyText; + if (crazyGamesSDK.isOnCrazyGames()) { + return crazyGamesSDK.createInviteLink(this.lobbyId); + } + if (!this.lobbyId) return ""; + return await this.buildCopyUrl(); + } + + private async handleCopy() { + const text = await this.resolveCopyText(); + if (!text) { + alert("Error copying game id"); + return; + } + await copyToClipboard( + text, + () => (this.copySuccess = true), + () => (this.copySuccess = false), + ); + } + + private canCopy() { + return Boolean(this.copyText || this.lobbyId); + } + + render() { + const canCopy = this.canCopy(); + const allowMask = this.showVisibilityToggle && !this.compact; + const rawLabel = this.displayText || this.lobbyId || this.copyText; + const label = this.copySuccess + ? translateText("common.copied") + : allowMask && !this.lobbyIdVisible + ? this.maskLabel + : rawLabel; + const disabledClass = canCopy ? "" : "opacity-60 cursor-not-allowed"; + const toggleDisabled = !this.lobbyId; + const toggleClass = toggleDisabled ? "opacity-60 cursor-not-allowed" : ""; + + if (this.compact) { + return html` + + `; + } + + return html` +
+ ${this.showVisibilityToggle + ? html`` + : ""} + + ${this.showCopyIcon + ? html`` + : ""} +
+ `; + } +} diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 74ea1b498..c197306b4 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -104,11 +104,17 @@ export class DesktopNavBar extends LitElement { data-page="page-news" data-i18n="main.news" > - +
+ + +
@@ -125,9 +157,10 @@ export class LobbyTeamView extends LitElement { return html`${repeat( this.clients, (c) => c.clientID ?? c.username, - (client) => - html` - ${client.username} + (client) => { + const displayName = this.displayUsername(client); + return html` + ${displayName} ${client.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")}) this.onKickPlayer?.(client.clientID)} aria-label=${translateText("host_modal.remove_player", { - username: client.username, + username: displayName, })} > × ` : html``} - `, + `; + }, )} `; } private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) { + const effectiveNationCount = this.getEffectiveNationCount(); const displayCount = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : preview.players.length; const maxTeamSize = preview.team === ColoredTeams.Nations - ? this.nationCount + ? effectiveNationCount : this.teamMaxSize; return html` @@ -180,11 +215,12 @@ export class LobbyTeamView extends LitElement { : repeat( preview.players, (p) => p.clientID ?? p.username, - (p) => - html`
{ + const displayName = this.displayUsername(p); + return html`
- ${p.username} + ${displayName} ${p.clientID === this.lobbyCreatorClientID ? html`(${translateText("host_modal.host_badge")}) × ` : html``} -
`, +
`; + }, )}
@@ -212,7 +249,7 @@ export class LobbyTeamView extends LitElement { private getTeamList(): Team[] { if (this.gameMode !== GameMode.Team) return []; - const playerCount = this.clients.length + this.nationCount; + const playerCount = this.clients.length + this.getEffectiveNationCount(); const config = this.teamCount; if (config === HumansVsNations) { @@ -276,7 +313,7 @@ export class LobbyTeamView extends LitElement { const assignment = assignTeamsLobbyPreview( players, teams, - this.nationCount, + this.getEffectiveNationCount(), ); const buckets = new Map(); for (const t of teams) buckets.set(t, []); @@ -300,7 +337,9 @@ export class LobbyTeamView extends LitElement { // Fallback: divide players across teams; guard against 0 and empty lobbies this.teamMaxSize = Math.max( 1, - Math.ceil((this.clients.length + this.nationCount) / teams.length), + Math.ceil( + (this.clients.length + this.getEffectiveNationCount()) / teams.length, + ), ); } this.teamPreview = teams.map((t) => ({ @@ -308,4 +347,34 @@ export class LobbyTeamView extends LitElement { players: buckets.get(t) ?? [], })); } + + /** + * Returns the effective nation count for display purposes. + * In HumansVsNations mode, this equals the number of human players. + * For compact maps, only 25% of nations are used. + * Otherwise, it uses the manifest nation count (or 0 if nations are disabled). + */ + private getEffectiveNationCount(): number { + if (this.disableNations) { + return 0; + } + if (this.gameMode === GameMode.Team && this.teamCount === HumansVsNations) { + return this.clients.length; + } + return getCompactMapNationCount(this.nationCount, this.isCompactMap); + } + + private displayUsername(client: ClientInfo): string { + if (!this.userSettings.anonymousNames()) { + return client.username; + } + + if (this.currentClientID && client.clientID === this.currentClientID) { + return client.username; + } + + return ( + createRandomName(client.username, PlayerType.Human) ?? client.username + ); + } } diff --git a/src/client/components/MobileNavBar.ts b/src/client/components/MobileNavBar.ts index 09cce67d4..900ee2a3b 100644 --- a/src/client/components/MobileNavBar.ts +++ b/src/client/components/MobileNavBar.ts @@ -124,18 +124,24 @@ export class MobileNavBar extends LitElement { data-page="page-stats" data-i18n="main.stats" > - +
+ + +
diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 037dc4743..6d6b94b18 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -72,7 +72,7 @@ export class PatternButton extends LitElement { return html`
- +
@@ -138,32 +138,7 @@ export class PlayPage extends LitElement {
- - - - - +
diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index baa877b4c..4e521ecd9 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -25,6 +25,9 @@ export class OModal extends LitElement { @property({ type: Boolean }) public hideHeader = false; + @property({ type: String }) + public maxWidth = ""; + public onClose?: () => void; public open() { @@ -60,13 +63,15 @@ export class OModal extends LitElement { render() { const backdropClass = this.inline ? "relative z-10 w-full h-full flex items-stretch bg-transparent" - : "fixed inset-0 z-[9999] bg-black/70 flex items-center justify-center overflow-hidden"; + : "fixed inset-0 z-[9999] bg-black/60 flex items-center justify-center overflow-hidden"; const wrapperClass = this.inline ? "relative flex flex-col w-full h-full m-0 max-w-full max-h-none shadow-none" : `relative flex flex-col w-[90%] min-w-[400px] max-w-[900px] m-8 rounded-lg shadow-[0_20px_60px_rgba(0,0,0,0.8)] max-h-[calc(100vh-4rem)] ${ this.alwaysMaximized ? "h-auto" : "" }`; + const wrapperStyle = + !this.inline && this.maxWidth ? `max-width: ${this.maxWidth};` : ""; return html` ${this.isModalOpen @@ -78,18 +83,19 @@ export class OModal extends LitElement {
e.stopPropagation()} class="${wrapperClass}" + style="${wrapperStyle}" > ${this.inline || this.hideCloseButton ? html`` : html`
this.close()} > ✕
`} ${!this.hideHeader && this.title ? html`
${this.title}
` diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts index 015d6ae40..dff65db0f 100644 --- a/src/client/components/baseComponents/ranking/GameInfoRanking.ts +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -2,17 +2,24 @@ import { AnalyticsRecord, PlayerRecord } from "../../../../core/Schemas"; import { GOLD_INDEX_STEAL, GOLD_INDEX_TRADE, + GOLD_INDEX_TRAIN_OTHER, + GOLD_INDEX_TRAIN_SELF, GOLD_INDEX_WAR, + PLAYER_INDEX_BOT, + PLAYER_INDEX_HUMAN, + PLAYER_INDEX_NATION, } from "../../../../core/StatsSchemas"; export enum RankType { - Conquests = "Conquests", + ConquestHumans = "ConquestHumans", + ConquestBots = "ConquestBots", Atoms = "Atoms", Hydros = "Hydros", MIRV = "MIRV", TotalGold = "TotalGold", StolenGold = "StolenGold", - TradedGold = "TradedGold", + NavalTrade = "NavalTrade", + TrainTrade = "TrainTrade", ConqueredGold = "ConqueredGold", Lifetime = "Lifetime", } @@ -24,7 +31,7 @@ export interface PlayerInfo { tag?: string; killedAt?: number; gold: bigint[]; - conquests: number; + conquests: bigint[]; flag?: string; winner: boolean; atoms: number; @@ -76,12 +83,13 @@ export class Ranking { username = match[2]; } const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0)); + const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0)); players[player.clientID] = { id: player.clientID, rawUsername: player.username, username, tag: player.clanTag, - conquests: Number(stats.conquests) || 0, + conquests, flag: player.cosmetics?.flag ?? undefined, killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined, gold, @@ -122,8 +130,13 @@ export class Ranking { return (player.killedAt / Math.max(this.duration, 1)) * 10; } return 100; - case RankType.Conquests: - return player.conquests; + case RankType.ConquestHumans: + return Number(player.conquests[PLAYER_INDEX_HUMAN] ?? 0n); + case RankType.ConquestBots: + return ( + Number(player.conquests[PLAYER_INDEX_BOT] ?? 0n) + + Number(player.conquests[PLAYER_INDEX_NATION] ?? 0n) + ); case RankType.Atoms: return player.atoms; case RankType.Hydros: @@ -134,10 +147,15 @@ export class Ranking { return Number(player.gold.reduce((sum, gold) => sum + gold, 0n)); case RankType.StolenGold: return Number(player.gold[GOLD_INDEX_STEAL] ?? 0n); - case RankType.TradedGold: + case RankType.NavalTrade: return Number(player.gold[GOLD_INDEX_TRADE] ?? 0n); case RankType.ConqueredGold: return Number(player.gold[GOLD_INDEX_WAR] ?? 0n); + case RankType.TrainTrade: { + const ownTrains = player.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n; + const otherTrains = player.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n; + return Number(ownTrains + otherTrains); + } } } diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index 2ebe635ed..ed3cfc6ba 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -1,5 +1,10 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { + GOLD_INDEX_TRADE, + GOLD_INDEX_TRAIN_OTHER, + GOLD_INDEX_TRAIN_SELF, +} from "src/core/StatsSchemas"; import { renderNumber } from "../../../Utils"; import { PlayerInfo, RankType } from "./GameInfoRanking"; @@ -22,15 +27,13 @@ export class PlayerRow extends LitElement { const visibleBorder = player.winner || this.currentPlayer; return html`
  • ${Number(this.score).toFixed(0)}
    @@ -99,24 +105,25 @@ export class PlayerRow extends LitElement { const width = Math.min(Math.max((this.score / bestScore) * 100, 0), 100); return html`
    -
    +
    `; } - private renderBombType(value: number, highlight: boolean) { + + private renderMultiScoreType(value: number, highlight: boolean) { return html`
    - ${value} + ${renderNumber(value)}
    `; } @@ -124,17 +131,17 @@ export class PlayerRow extends LitElement { private renderAllBombs() { return html`
    - ${this.renderBombType( + ${this.renderMultiScoreType( this.player.atoms, this.rankType === RankType.Atoms, )} / - ${this.renderBombType( + ${this.renderMultiScoreType( this.player.hydros, this.rankType === RankType.Hydros, )} / - ${this.renderBombType( + ${this.renderMultiScoreType( this.player.mirv, this.rankType === RankType.MIRV, )} @@ -142,9 +149,28 @@ export class PlayerRow extends LitElement { `; } + private renderAllTrades() { + const navalTrade = this.player.gold[GOLD_INDEX_TRADE] ?? 0n; + const ownTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_SELF] ?? 0n; + const otherTrainTrade = this.player.gold[GOLD_INDEX_TRAIN_OTHER] ?? 0n; + return html` +
    + ${this.renderMultiScoreType( + Number(ownTrainTrade + otherTrainTrade), + this.rankType === RankType.TrainTrade, + )} + / + ${this.renderMultiScoreType( + Number(navalTrade), + this.rankType === RankType.NavalTrade, + )} +
    + `; + } + private renderBombScore() { return html` -
    +
    ${this.renderPlayerIcon()}
    ${this.renderPlayerName()} ${this.renderAllBombs()} @@ -157,13 +183,12 @@ export class PlayerRow extends LitElement { return html`
    ${this.renderPlayerIcon()} -
    - ${this.renderPlayerName()} -
    +
    ${this.renderPlayerName()}
    +
    ${renderNumber(this.score)}
    @@ -172,12 +197,32 @@ export class PlayerRow extends LitElement { `; } + private renderTradeScore() { + return html` +
    +
    + ${this.renderPlayerIcon()} +
    + ${this.renderPlayerName()} +
    +
    + +
    +
    + ${this.renderAllTrades()} +
    + +
    +
    + `; + } + private renderPlayerName() { return html`
    ${this.player.tag ? this.renderTag(this.player.tag) : ""}
    ${this.player.username}
    @@ -188,7 +233,7 @@ export class PlayerRow extends LitElement { private renderTag(tag: string) { return html`
    ${tag}
    diff --git a/src/client/components/baseComponents/ranking/RankingControls.ts b/src/client/components/baseComponents/ranking/RankingControls.ts index 59e3ea76c..527681c7f 100644 --- a/src/client/components/baseComponents/ranking/RankingControls.ts +++ b/src/client/components/baseComponents/ranking/RankingControls.ts @@ -7,19 +7,28 @@ const economyRankings = new Set([ RankType.TotalGold, RankType.StolenGold, RankType.ConqueredGold, - RankType.TradedGold, + RankType.NavalTrade, + RankType.TrainTrade, ]); -const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); const warRankings = new Set([ - RankType.Conquests, + RankType.ConquestHumans, + RankType.ConquestBots, RankType.Atoms, RankType.Hydros, RankType.MIRV, ]); +const tradeRankings = new Set([RankType.NavalTrade, RankType.TrainTrade]); +const bombRankings = new Set([RankType.Atoms, RankType.Hydros, RankType.MIRV]); +const conquestRankings = new Set([ + RankType.ConquestHumans, + RankType.ConquestBots, +]); const isEconomyRanking = (t: RankType) => economyRankings.has(t); +const isTradeRanking = (t: RankType) => tradeRankings.has(t); const isBombRanking = (t: RankType) => bombRankings.has(t); const isWarRanking = (t: RankType) => warRankings.has(t); +const isConquestRanking = (t: RankType) => conquestRankings.has(t); @customElement("ranking-controls") export class RankingControls extends LitElement { @@ -38,7 +47,7 @@ export class RankingControls extends LitElement { "game_info_modal.duration", )} ${this.renderButton( - RankType.Conquests, + RankType.ConquestHumans, isWarRanking(this.rankType), "game_info_modal.war", )} @@ -54,9 +63,9 @@ export class RankingControls extends LitElement { private renderButton(type: RankType, active: boolean, label: string) { return html`
    @@ -87,7 +96,6 @@ export class RankingControls extends LitElement { if (!isEconomyRanking(this.rankType)) return ""; const econButtons = [ - [RankType.TradedGold, "game_info_modal.trade"], [RankType.StolenGold, "game_info_modal.pirate"], [RankType.ConqueredGold, "game_info_modal.conquered"], [RankType.TotalGold, "game_info_modal.total_gold"], @@ -95,6 +103,11 @@ export class RankingControls extends LitElement { return html`
    + ${this.renderSubButton( + RankType.NavalTrade, + isTradeRanking(this.rankType), + "game_info_modal.trade", + )} ${econButtons.map(([type, label]) => this.renderSubButton(type as RankType, this.rankType === type, label), )} @@ -106,8 +119,8 @@ export class RankingControls extends LitElement { return html` + +
    +
    + ${this.showAllMaps ? this.renderAllMaps() : this.renderFeaturedMaps()} +
    +

    + ${translateText("map_categories.special")} +

    +
    + +
    +
    +
    + `; + } +} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 06f1b9202..12dcd97e7 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -6,7 +6,6 @@ import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; import { FrameProfiler } from "./FrameProfiler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; -import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; @@ -20,6 +19,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { ImmunityTimer } from "./layers/ImmunityTimer"; +import { InGameHeaderAd } from "./layers/InGameHeaderAd"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; @@ -245,6 +245,14 @@ export function createRenderer( } immunityTimer.game = game; + const inGameHeaderAd = document.querySelector( + "in-game-header-ad", + ) as InGameHeaderAd; + if (!(inGameHeaderAd instanceof InGameHeaderAd)) { + console.error("in-game header ad not found"); + } + inGameHeaderAd.game = game; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -259,7 +267,7 @@ export function createRenderer( new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), - new DynamicUILayer(game, transformHandler), + new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), eventsDisplay, chatDisplay, @@ -288,7 +296,7 @@ export function createRenderer( playerPanel, headsUpMessage, multiTabModal, - new AdTimer(game), + inGameHeaderAd, alertFrame, performanceOverlay, ]; @@ -306,6 +314,7 @@ export function createRenderer( export class GameRenderer { private context: CanvasRenderingContext2D; + private layerTickState = new Map(); constructor( private game: GameView, @@ -417,7 +426,28 @@ export class GameRenderer { } tick() { - this.layers.forEach((l) => l.tick?.()); + const nowMs = performance.now(); + + for (const layer of this.layers) { + if (!layer.tick) { + continue; + } + + const state = this.layerTickState.get(layer) ?? { + lastTickAtMs: -Infinity, + }; + + const intervalMs = layer.getTickIntervalMs?.() ?? 0; + if (intervalMs > 0 && nowMs - state.lastTickAtMs < intervalMs) { + this.layerTickState.set(layer, state); + continue; + } + + state.lastTickAtMs = nowMs; + this.layerTickState.set(layer, state); + + layer.tick(); + } } resize(width: number, height: number): void { diff --git a/src/client/graphics/NameBoxCalculator.ts b/src/client/graphics/NameBoxCalculator.ts index 67f21916c..fa2165512 100644 --- a/src/client/graphics/NameBoxCalculator.ts +++ b/src/client/graphics/NameBoxCalculator.ts @@ -92,6 +92,7 @@ export function createGrid( const tile = game.ref(cell.x, cell.y); grid[x - scaledBoundingBox.min.x][y - scaledBoundingBox.min.y] = game.isLake(tile) || + game.isShore(tile) || game.owner(tile) === player || game.hasFallout(tile); } diff --git a/src/client/graphics/layers/AdTimer.ts b/src/client/graphics/layers/AdTimer.ts deleted file mode 100644 index 367744df9..000000000 --- a/src/client/graphics/layers/AdTimer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { GameView } from "../../../core/game/GameView"; -import { Layer } from "./Layer"; - -const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minute - -export class AdTimer implements Layer { - private isHidden: boolean = false; - - constructor(private g: GameView) {} - - init() {} - - public async tick() { - if (this.isHidden) { - return; - } - - const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns(); - if (gameTicks > AD_SHOW_TICKS) { - console.log("destroying sticky ads"); - window.fusetag?.que?.push(() => { - window.fusetag?.destroySticky?.(); - }); - this.isHidden = true; - return; - } - } -} diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index cbe0ef70c..a6b03abad 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -39,6 +39,10 @@ export class ControlPanel extends LitElement implements Layer { private _lastTroopIncreaseRate: number; + getTickIntervalMs() { + return 100; + } + init() { this.attackRatio = Number( localStorage.getItem("settings.attackRatio") ?? "0.2", @@ -81,9 +85,7 @@ export class ControlPanel extends LitElement implements Layer { return; } - if (this.game.ticks() % 5 === 0) { - this.updateTroopIncrease(); - } + this.updateTroopIncrease(); this._maxTroops = this.game.config().maxTroops(player); this._gold = player.gold(); @@ -201,8 +203,7 @@ export class ControlPanel extends LitElement implements Layer { (${renderTroops( (this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio, - )} - ) + )}) diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts index 66810b08a..b151a1b83 100644 --- a/src/client/graphics/layers/DynamicUILayer.ts +++ b/src/client/graphics/layers/DynamicUILayer.ts @@ -1,4 +1,5 @@ import { renderNumber } from "src/client/Utils"; +import { EventBus } from "src/core/EventBus"; import { UnitType } from "src/core/game/Game"; import { BonusEventUpdate, @@ -6,7 +7,9 @@ import { GameUpdateType, } from "src/core/game/GameUpdates"; import type { GameView, UnitView } from "../../../core/game/GameView"; +import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; +import { MoveIndicatorUI } from "../ui/MoveIndicatorUI"; import { NavalTarget } from "../ui/NavalTarget"; import { NukeTelegraph } from "../ui/NukeTelegraph"; import { TextIndicator } from "../ui/TextIndicator"; @@ -24,8 +27,18 @@ export class DynamicUILayer implements Layer { constructor( private readonly game: GameView, private transformHandler: TransformHandler, + private eventBus: EventBus, ) {} + init() { + // Listen for warship move clicks for MoveIndicatorUI + this.eventBus.on(MoveWarshipIntentEvent, (e) => { + const x = this.game.x(e.tile); + const y = this.game.y(e.tile); + this.uiElements.push(new MoveIndicatorUI(this.transformHandler, x, y)); + }); + } + shouldTransform(): boolean { return false; } @@ -103,14 +116,25 @@ export class DynamicUILayer implements Layer { } onBombEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if ( + this.createdThisTick(unit) && + (unit.owner() === myPlayer || unit.owner().isOnSameTeam(myPlayer)) + ) { const target = new NukeTelegraph(this.transformHandler, this.game, unit); this.uiElements.push(target); } } onTransportShipEvent(unit: UnitView) { - if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + return; + } + if (this.createdThisTick(unit) && unit.owner() === myPlayer) { const target = new NavalTarget(this.transformHandler, this.game, unit); this.uiElements.push(target); } @@ -133,11 +157,6 @@ export class DynamicUILayer implements Layer { } } - private isOwnedByPlayer(unit: UnitView): boolean { - const my = this.game.myPlayer(); - return my !== null && unit.owner() === my; - } - private createdThisTick(unit: UnitView): boolean { return unit.createdAt() === this.game.ticks(); } diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index e86317a7c..0523a480c 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -2,10 +2,9 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; -import { PauseGameIntentEvent } from "../../Transport"; +import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; @@ -50,16 +49,20 @@ export class GameRightSidebar extends LitElement implements Layer { this._isVisible = true; this.game.inSpawnPhase(); + this.eventBus.on(SendWinnerEvent, () => { + this.hasWinner = true; + this.requestUpdate(); + }); + this.requestUpdate(); } + getTickIntervalMs() { + return 250; + } + tick() { // Timer logic - const updates = this.game.updatesSinceLastTick(); - if (updates) { - this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; - } - // Check if the player is the lobby creator if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) { this.isLobbyCreator = true; @@ -67,18 +70,24 @@ export class GameRightSidebar extends LitElement implements Layer { } const maxTimerValue = this.game.config().gameConfig().maxTimerValue; + const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); + const ticks = this.game.ticks(); + const gameTicks = Math.max(0, ticks - spawnPhaseTurns); + const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second + + if (this.game.inSpawnPhase()) { + this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0; + return; + } + + if (this.hasWinner) { + return; + } + if (maxTimerValue !== undefined) { - if (this.game.inSpawnPhase()) { - this.timer = maxTimerValue * 60; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer = Math.max(0, this.timer - 1); - } + this.timer = Math.max(0, maxTimerValue * 60 - elapsedSeconds); } else { - if (this.game.inSpawnPhase()) { - this.timer = 0; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer++; - } + this.timer = elapsedSeconds; } } @@ -105,10 +114,15 @@ export class GameRightSidebar extends LitElement implements Layer { private onPauseButtonClick() { this.isPaused = !this.isPaused; + if (this.isPaused) { + crazyGamesSDK.gameplayStop(); + } else { + crazyGamesSDK.gameplayStart(); + } this.eventBus.emit(new PauseGameIntentEvent(this.isPaused)); } - private onExitButtonClick() { + private async onExitButtonClick() { const isAlive = this.game.myPlayer()?.isAlive(); if (isAlive) { const isConfirmed = confirm( @@ -116,10 +130,10 @@ export class GameRightSidebar extends LitElement implements Layer { ); if (!isConfirmed) return; } - crazyGamesSDK.gameplayStop().then(() => { - // redirect to the home page - window.location.href = "/"; - }); + await crazyGamesSDK.requestMidgameAd(); + await crazyGamesSDK.gameplayStop(); + // redirect to the home page + window.location.href = "/"; } private onSettingsButtonClick() { diff --git a/src/client/graphics/layers/InGameHeaderAd.ts b/src/client/graphics/layers/InGameHeaderAd.ts new file mode 100644 index 000000000..f3925508a --- /dev/null +++ b/src/client/graphics/layers/InGameHeaderAd.ts @@ -0,0 +1,112 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +const AD_SHOW_TICKS = 2 * 60 * 10; // 2 minutes +const HEADER_AD_TYPE = "standard_iab_head1"; +const HEADER_AD_CONTAINER_ID = "header-ad-container"; +const TWO_XL_BREAKPOINT = 1536; + +@customElement("in-game-header-ad") +export class InGameHeaderAd extends LitElement implements Layer { + public game: GameView; + + private isHidden: boolean = false; + private adLoaded: boolean = false; + private shouldShow: boolean = false; + + createRenderRoot() { + return this; + } + + init() { + this.showHeaderAd(); + } + + private showHeaderAd(): void { + // Don't show header ad on screens smaller than 2xl + if (window.innerWidth < TWO_XL_BREAKPOINT) { + return; + } + if (!window.adsEnabled) { + return; + } + + this.shouldShow = true; + this.requestUpdate(); + + // Wait for the element to render before loading the ad + this.updateComplete.then(() => { + this.loadAd(); + }); + } + + private loadAd(): void { + if (!window.ramp) { + console.warn("Playwire RAMP not available for header ad"); + return; + } + + try { + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { + type: HEADER_AD_TYPE, + selectorId: HEADER_AD_CONTAINER_ID, + }, + ]); + this.adLoaded = true; + console.log("Header ad loaded:", HEADER_AD_TYPE); + } catch (e) { + console.error("Failed to add header ad:", e); + } + }); + } catch (error) { + console.error("Failed to load header ad:", error); + } + } + + private hideHeaderAd(): void { + this.shouldShow = false; + this.adLoaded = false; + this.requestUpdate(); + } + + public tick() { + if (this.isHidden) { + return; + } + + const gameTicks = + this.game.ticks() - this.game.config().numSpawnPhaseTurns(); + if (gameTicks > AD_SHOW_TICKS) { + console.log("destroying header ad and refreshing PageOS"); + this.hideHeaderAd(); + this.isHidden = true; + + if (window.PageOS?.session?.newPageView) { + window.PageOS.session.newPageView(); + } + return; + } + } + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.shouldShow) { + return html``; + } + + return html` + + `; + } +} diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 239937435..456648f79 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -1,6 +1,9 @@ export interface Layer { init?: () => void; tick?: () => void; + // Optional hint to throttle expensive ticks by wall-clock. + // If omitted or <= 0, the layer ticks whenever GameRenderer ticks. + getTickIntervalMs?: () => number; renderLayer?: (context: CanvasRenderingContext2D) => void; shouldTransform?: () => boolean; redraw?: () => void; diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 5dd8793f3..19aec6643 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -55,12 +55,14 @@ export class Leaderboard extends LitElement implements Layer { init() {} + getTickIntervalMs() { + return 1000; + } + tick() { if (this.game === null) throw new Error("Not initialized"); if (!this.visible) return; - if (this.game.ticks() % 10 === 0) { - this.updateLeaderboard(); - } + this.updateLeaderboard(); } private setSort(key: "tiles" | "gold" | "maxtroops") { diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 3151c6a48..989b5aa79 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -33,6 +33,10 @@ export class MainRadialMenu extends LitElement implements Layer { private clickedTile: TileRef | null = null; + getTickIntervalMs() { + return 500; + } + constructor( private eventBus: EventBus, private game: GameView, @@ -134,7 +138,9 @@ export class MainRadialMenu extends LitElement implements Layer { }; const isFriendlyTarget = - recipient !== null && recipient.isFriendly(myPlayer); + recipient !== null && + recipient.isFriendly(myPlayer) && + !recipient.isDisconnected(); this.radialMenu.setCenterButtonAppearance( isFriendlyTarget ? donateTroopIcon : swordIcon, @@ -154,18 +160,16 @@ export class MainRadialMenu extends LitElement implements Layer { async tick() { if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return; - if (this.game.ticks() % 5 === 0) { - this.game - .myPlayer()! - .actions(this.clickedTile) - .then((actions) => { - this.updatePlayerActions( - this.game.myPlayer()!, - actions, - this.clickedTile!, - ); - }); - } + this.game + .myPlayer()! + .actions(this.clickedTile) + .then((actions) => { + this.updatePlayerActions( + this.game.myPlayer()!, + actions, + this.clickedTile!, + ); + }); } renderLayer(context: CanvasRenderingContext2D) { diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 1c0b94a22..e23d4d609 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -133,11 +133,11 @@ export class NameLayer implements Layer { } } - public tick() { - if (this.game.ticks() % 10 !== 0) { - return; - } + getTickIntervalMs() { + return 1000; + } + public tick() { // Precompute the first-place player for performance this.firstPlace = getFirstPlacePlayer(this.game); diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 36bf818c7..0d1dfc9d0 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -1,4 +1,5 @@ import { EventBus } from "../../../core/EventBus"; +import { listNukeBreakAlliance } from "../../../core/execution/Util"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; @@ -258,6 +259,18 @@ export class NukeTrajectoryPreviewLayer implements Layer { break; } } + const playersToBreakAllianceWith = listNukeBreakAlliance({ + game: this.game, + targetTile, + magnitude: this.game.config().nukeMagnitudes(ghostStructure), + allySmallIds: new Set( + this.game + .myPlayer() + ?.allies() + .map((a) => a.smallID()), + ), + threshold: this.game.config().nukeAllianceBreakThreshold(), + }); // Find the point where SAM can intercept this.targetedIndex = this.trajectoryPoints.length; // Check trajectory @@ -270,7 +283,8 @@ export class NukeTrajectoryPreviewLayer implements Layer { )) { if ( sam.unit.owner().isMe() || - this.game.myPlayer()?.isFriendly(sam.unit.owner()) + (this.game.myPlayer()?.isFriendly(sam.unit.owner()) && + !playersToBreakAllianceWith.has(sam.unit.owner().smallID())) ) { continue; } diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 50a782e10..ad27aeaa5 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -80,9 +80,9 @@ export class PerformanceOverlay extends LitElement implements Layer { static styles = css` .performance-overlay { position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); + top: var(--top, 20px); + left: var(--left, 50%); + transform: var(--transform, translateX(-50%)); background: rgba(0, 0, 0, 0.8); color: white; padding: 8px 16px; @@ -209,6 +209,7 @@ export class PerformanceOverlay extends LitElement implements Layer { .layer-bar-fill { height: 100%; + width: var(--width); background: #38bdf8; border-radius: 3px; } @@ -551,10 +552,9 @@ export class PerformanceOverlay extends LitElement implements Layer { return html`
    ` : ""} + ${unit.type() === UnitType.TransportShip + ? html` +
    + ${translateText("player_info_overlay.troops")}: + ${renderTroops(unit.troops())} +
    + ` + : ""}
    `; diff --git a/src/client/graphics/layers/PlayerModerationModal.ts b/src/client/graphics/layers/PlayerModerationModal.ts new file mode 100644 index 000000000..c51f9efc1 --- /dev/null +++ b/src/client/graphics/layers/PlayerModerationModal.ts @@ -0,0 +1,167 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { PlayerType } from "../../../core/game/Game"; +import { PlayerView } from "../../../core/game/GameView"; +import { actionButton } from "../../components/ui/ActionButton"; +import { SendKickPlayerIntentEvent } from "../../Transport"; +import { translateText } from "../../Utils"; +import kickIcon from "/images/ExitIconWhite.svg?url"; +import shieldIcon from "/images/ShieldIconWhite.svg?url"; + +@customElement("player-moderation-modal") +export class PlayerModerationModal extends LitElement { + @property({ attribute: false }) eventBus: EventBus | null = null; + @property({ attribute: false }) myPlayer: PlayerView | null = null; + @property({ attribute: false }) target: PlayerView | null = null; + + @property({ type: Boolean }) open: boolean = false; + @property({ type: Boolean }) alreadyKicked: boolean = false; + + createRenderRoot() { + return this; + } + + updated(changed: Map) { + if (changed.has("open") && this.open) { + queueMicrotask(() => + (this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(), + ); + } + } + + private closeModal() { + this.dispatchEvent(new CustomEvent("close")); + } + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + this.closeModal(); + } + }; + + private canKick(my: PlayerView, other: PlayerView): boolean { + return ( + my.isLobbyCreator() && + other !== my && + other.type() === PlayerType.Human && + !!other.clientID() + ); + } + + private handleKickClick = (e: MouseEvent) => { + e.stopPropagation(); + + const my = this.myPlayer; + const other = this.target; + const eventBus = this.eventBus; + + if (!my || !other) return; + if (!this.canKick(my, other) || this.alreadyKicked) return; + if (!eventBus) return; + + const targetClientID = other.clientID(); + if (!targetClientID || targetClientID.length === 0) return; + + const confirmed = confirm( + translateText("player_panel.kick_confirm", { name: other.name() }), + ); + if (!confirmed) return; + + eventBus.emit(new SendKickPlayerIntentEvent(targetClientID)); + this.dispatchEvent( + new CustomEvent("kicked", { detail: { playerId: String(other.id()) } }), + ); + this.closeModal(); + }; + + render() { + if (!this.open) return html``; + + const my = this.myPlayer; + const other = this.target; + if (!my || !other) return html``; + + const canKick = this.canKick(my, other); + const alreadyKicked = this.alreadyKicked; + + const moderationTitle = translateText("player_panel.moderation"); + const kickTitle = alreadyKicked + ? translateText("player_panel.kicked") + : translateText("player_panel.kick"); + + return html` +
    +
    this.closeModal()} + >
    + + +
    + `; + } +} diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 20883f88e..674b83e15 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -37,12 +37,14 @@ import { UIState } from "../UIState"; import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; +import "./PlayerModerationModal"; import "./SendResourceModal"; import allianceIcon from "/images/AllianceIconWhite.svg?url"; import chatIcon from "/images/ChatIconWhite.svg?url"; import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url"; import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url"; import emojiIcon from "/images/EmojiIconWhite.svg?url"; +import shieldIcon from "/images/ShieldIconWhite.svg?url"; import stopTradingIcon from "/images/StopIconWhite.png?url"; import targetIcon from "/images/TargetIconWhite.svg?url"; import startTradingIcon from "/images/TradingIconWhite.png?url"; @@ -59,6 +61,7 @@ export class PlayerPanel extends LitElement implements Layer { private actions: PlayerActions | null = null; private tile: TileRef | null = null; private _profileForPlayerId: number | null = null; + private kickedPlayerIDs = new Set(); @state() private sendTarget: PlayerView | null = null; @state() private sendMode: "troops" | "gold" | "none" = "none"; @@ -67,6 +70,7 @@ export class PlayerPanel extends LitElement implements Layer { @state() private allianceExpirySeconds: number | null = null; @state() private otherProfile: PlayerProfile | null = null; @state() private suppressNextHide: boolean = false; + @state() private moderationTarget: PlayerView | null = null; private ctModal: ChatModal; @@ -142,6 +146,7 @@ export class PlayerPanel extends LitElement implements Layer { public show(actions: PlayerActions, tile: TileRef) { this.actions = actions; this.tile = tile; + this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } @@ -156,6 +161,7 @@ export class PlayerPanel extends LitElement implements Layer { this.tile = tile; this.sendTarget = target; this.sendMode = "gold"; + this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } @@ -164,6 +170,7 @@ export class PlayerPanel extends LitElement implements Layer { this.isVisible = false; this.sendMode = "none"; this.sendTarget = null; + this.moderationTarget = null; this.requestUpdate(); } @@ -305,6 +312,23 @@ export class PlayerPanel extends LitElement implements Layer { this.hide(); } + private openModeration(e: MouseEvent, other: PlayerView) { + e.stopPropagation(); + this.suppressNextHide = true; + this.moderationTarget = other; + } + + private closeModeration = () => { + this.moderationTarget = null; + }; + + private handleModerationKicked = (e: CustomEvent<{ playerId?: string }>) => { + const playerId = e.detail?.playerId; + if (playerId) this.kickedPlayerIDs.add(String(playerId)); + this.closeModeration(); + this.hide(); + }; + private handleToggleRocketDirection(e: Event) { e.stopPropagation(); const next = !this.uiState.rocketDirectionUp; @@ -419,6 +443,25 @@ export class PlayerPanel extends LitElement implements Layer { `; } + private renderModeration(my: PlayerView, other: PlayerView) { + if (!my.isLobbyCreator()) return html``; + const moderationTitle = translateText("player_panel.moderation"); + + return html` + +
    + ${actionButton({ + onClick: (e: MouseEvent) => this.openModeration(e, other), + icon: shieldIcon, + iconAlt: "Moderation", + title: moderationTitle, + label: moderationTitle, + type: "red", + })} +
    + `; + } + private renderRelationPillIfNation(other: PlayerView, my: PlayerView) { if (other.type() !== PlayerType.Nation) return html``; if (other.isTraitor()) return html``; @@ -804,6 +847,7 @@ export class PlayerPanel extends LitElement implements Layer { })}
    ` : ""} + ${this.renderModeration(my, other)}
    `; } @@ -914,6 +958,21 @@ export class PlayerPanel extends LitElement implements Layer { > ` : ""} + ${this.moderationTarget + ? html` + + ` + : ""} diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 090df03c1..ab4d0198f 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -907,9 +907,12 @@ export class RadialMenu implements Layer { .select(".center-button-hitbox") .style("cursor", enabled ? "pointer" : "not-allowed"); + // Use default color for back button, otherwise use the current center button color + const buttonColor = + state === "back" ? this.defaultCenterButtonColor : this.centerButtonColor; centerButton .select(".center-button-visible") - .attr("fill", enabled ? this.centerButtonColor : "#999999"); + .attr("fill", enabled ? buttonColor : "#999999"); centerButton .select(".center-button-icon") diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 82ff52079..11acf0c96 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -18,6 +18,7 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url"; import boatIcon from "/images/BoatIconWhite.svg?url"; import buildIcon from "/images/BuildIconWhite.svg?url"; import chatIcon from "/images/ChatIconWhite.svg?url"; +import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url"; import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url"; import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url"; import emojiIcon from "/images/EmojiIconWhite.svg?url"; @@ -118,6 +119,14 @@ function isFriendlyTarget(params: MenuElementParams): boolean { return isFriendly.call(selectedPlayer, params.myPlayer); } +function isDisconnectedTarget(params: MenuElementParams): boolean { + const selectedPlayer = params.selected; + if (selectedPlayer === null) return false; + const isDisconnected = (selectedPlayer as PlayerView).isDisconnected; + if (typeof isDisconnected !== "function") return false; + return isDisconnected.call(selectedPlayer); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoChatElement: MenuElement = { id: "info_chat", @@ -211,6 +220,15 @@ const allyBreakElement: MenuElement = { !!params.playerActions?.interaction?.canBreakAlliance, color: COLORS.breakAlly, icon: traitorIcon, + subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement], +}; + +const allyBreakConfirmElement: MenuElement = { + id: "ally_break_confirm", + name: "confirm", + disabled: () => false, + color: COLORS.breakAlly, + icon: checkmarkIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleBreakAlliance( params.myPlayer, @@ -220,6 +238,17 @@ const allyBreakElement: MenuElement = { }, }; +const allyBreakCancelElement: MenuElement = { + id: "ally_break_cancel", + name: "cancel", + disabled: () => false, + color: COLORS.info, + icon: xIcon, + action: (params: MenuElementParams) => { + params.closeMenu(); + }, +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyDonateGoldElement: MenuElement = { id: "ally_donate_gold", @@ -549,17 +578,7 @@ export const boatMenuElement: MenuElement = { color: COLORS.boat, action: async (params: MenuElementParams) => { - const spawn = await params.playerActionHandler.findBestTransportShipSpawn( - params.myPlayer, - params.tile, - ); - - params.playerActionHandler.handleBoatAttack( - params.myPlayer, - params.selected?.id() ?? null, - params.tile, - spawn !== false ? spawn : null, - ); + params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile); params.closeMenu(); }, @@ -573,13 +592,16 @@ export const centerButtonElement: CenterButtonElement = { return true; } if (params.game.inSpawnPhase()) { + if (params.game.config().isRandomSpawn()) { + return true; + } if (tileOwner.isPlayer()) { return true; } return false; } - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { return !params.playerActions.interaction?.canDonateTroops; } @@ -589,7 +611,7 @@ export const centerButtonElement: CenterButtonElement = { if (params.game.inSpawnPhase()) { params.playerActionHandler.handleSpawn(params.tile); } else { - if (isFriendlyTarget(params)) { + if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { const selectedPlayer = params.selected as PlayerView; const ratio = params.uiState?.attackRatio ?? 1; const troopsToDonate = Math.floor(ratio * params.myPlayer.troops()); @@ -645,7 +667,7 @@ export const rootMenuElement: MenuElement = { : [ boatMenuElement, ally, - isFriendlyTarget(params) + isFriendlyTarget(params) && !isDisconnectedTarget(params) ? donateGoldRadialElement : attackMenuElement, ]), diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index eb5cf4f1a..7b2b43eea 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -44,11 +44,13 @@ export class ReplayPanel extends LitElement implements Layer { } } + getTickIntervalMs() { + return 1000; + } + tick() { if (!this.visible) return; - if (this.game!.ticks() % 10 === 0) { - this.requestUpdate(); - } + this.requestUpdate(); } onReplaySpeedChange(value: ReplaySpeedMultiplier) { diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 92d8f1fe4..0a5b184bf 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -1,9 +1,10 @@ import { html, LitElement } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; +import { crazyGamesSDK } from "src/client/CrazyGamesSDK"; +import { PauseGameIntentEvent } from "src/client/Transport"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler"; -import { PauseGameIntentEvent } from "../../Transport"; import { translateText } from "../../Utils"; import SoundManager from "../../sound/SoundManager"; import { Layer } from "./Layer"; @@ -105,8 +106,14 @@ export class SettingsModal extends LitElement implements Layer { } private pauseGame(pause: boolean) { - if (this.shouldPause && !this.wasPausedWhenOpened) + if (this.shouldPause && !this.wasPausedWhenOpened) { + if (pause) { + crazyGamesSDK.gameplayStop(); + } else { + crazyGamesSDK.gameplayStart(); + } this.eventBus.emit(new PauseGameIntentEvent(pause)); + } } private onTerrainButtonClick() { diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index a8a7862dc..6f57e10c9 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -270,8 +270,8 @@ export class StructureIconsLayer implements Layer { myPlayer && (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) ) { - // Only check if player has allies - const allies = myPlayer.allies(); + // Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff + const allies = myPlayer.allies().filter((a) => !a.isDisconnected()); if (allies.length > 0) { targetingAlly = wouldNukeBreakAlliance({ game: this.game, diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index 1841a0636..5cf322e12 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -42,6 +42,10 @@ export class TeamStats extends LitElement implements Layer { init() {} + getTickIntervalMs() { + return 1000; + } + tick() { if (this.game.config().gameConfig().gameMode !== GameMode.Team) return; @@ -52,9 +56,7 @@ export class TeamStats extends LitElement implements Layer { if (!this.visible) return; - if (this.game.ticks() % 10 === 0) { - this.updateTeamStats(); - } + this.updateTeamStats(); } private updateTeamStats() { diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index 4b9a5d60c..c08bcb183 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -130,7 +130,7 @@ export class UnitDisplay extends LitElement implements Layer { return html`