diff --git a/docs/engine/hud.md b/docs/engine/hud.md index daa1c5cf..15931aeb 100644 --- a/docs/engine/hud.md +++ b/docs/engine/hud.md @@ -1060,6 +1060,13 @@ colour, while `hit_shake` is above zero, each row moves right by a random share **Improvement:** OpenReliant writes the ball's pixels into an image each frame, in the same way, and draws the image with the rest of the display, so it scales with it. +**Improvement:** a mod's `powerball.png` can be a whole multiple of 256 by 256 +([Modding](../guide/modding.md#pictures)). The ball's tables are then worked out at that multiple, +for the ball's pixels and the texture's alike, and the scroll and the shake grow by the same +factor, so the ball is as sharp as its picture. Each of the original's rows still takes one random +shake, which moves every row of pixels that stands for it, and the colours are the same at any +size. + ## The launch's date From the drop of the player's launch from the Reliant to its end ([Launches](launch.md#the-date)), diff --git a/docs/engine/loadout.md b/docs/engine/loadout.md index 6830560e..9983cb0f 100644 --- a/docs/engine/loadout.md +++ b/docs/engine/loadout.md @@ -217,6 +217,16 @@ The panels' textures are 256 by 256 (`panels.zig`), their text written into them The panels are added, so their black is clear and the room shows through. +**Improvement:** a mod's `fpanels.png` can be a whole multiple of 256 by 256 +([Modding](../guide/modding.md#pictures)). The panels' textures are then as many times as large, +and so is everything drawn on them: each of the original's pixels is a square of the texture's, and +the text is drawn from the outline fonts that stand in for `ld_handel.fnt` and `handels.fnt` +([Outline fonts](../formats/fnt.md#outline-fonts)). A bitmap glyph's strokes are drawn in the colour +of the remap's fullest level and fade through the other levels at their edges, so an outline glyph +is drawn in the fullest level's colour, on an edge one of the original's pixels wide in the colour +of the faintest: the figures in green on a dark edge, and the titles in black edged in green. +Without outline fonts (`--bitmap-fonts`), the bitmap glyphs are magnified. + ### The figures `loadout_load` works out each ship's figures as it loads (`bars.zig`): speed, acceleration, agility, shield power, shield recharge and armour as bars of 3 to 10 segments, placing each ship's figure between the least and the most of the fighters the ITAC lists (`loadout_ship_bars_init`, `0x00426600`; `range_scale`, `0x004504F0`), its afterburner fuel in seconds, and its crew. The missiles' figures are worked out the same way, and the missile page's info panel shows them. diff --git a/docs/formats/fnt.md b/docs/formats/fnt.md index 67844b20..08a49f6e 100644 --- a/docs/formats/fnt.md +++ b/docs/formats/fnt.md @@ -49,14 +49,17 @@ renders the glyphs ([Platform](../port/platform.md#fonts)). ([Modding](../guide/modding.md#fonts)). Otherwise Newtown, which is built into OpenReliant ([`deps/newtown`](../../deps/newtown/README.md)), replaces the original Handel Gothic fonts: `optfnt.fnt` and `smlfnt2.fnt`, the large and small menu fonts; `itacbig.fnt` and `itacsml.fnt`, - the ITAC fonts; `blufont.fnt`, the flight display font; and `ld_handel.fnt`, the loadout tooltip - font. A `.fnt` file in a mod is drawn from its bitmaps. -- **Palette fonts.** The flight display font and the loadout tooltip font are drawn through a + the ITAC fonts; `blufont.fnt`, the flight display font; and `ld_handel.fnt` and `handels.fnt`, the + loadout's fonts. A `.fnt` file in a mod is drawn from its bitmaps. +- **Palette fonts.** The flight display font and the loadout's fonts are drawn through a palette, with each pixel of a glyph in its own colour. For these fonts, the ink is the brightest colour their digits and letters use, and each colour counts as partial coverage by how close it is to the ink, since the bitmap's pixels fade towards black where the glyph covers them less. Outline glyphs are drawn in the ink colour, and a glyph that uses colours the digits and letters don't use, such as a symbol, keeps its bitmap. +- **The loadout's panels.** The loadout writes its panels' text into their textures, through remaps + of their own. Outline fonts draw it only in panels larger than the original's, from a mod's larger + picture, in the colours of the remap ([Loadout](../engine/loadout.md#the-panels)). - **Weight.** Newtown's strokes are made as heavy as the bitmap font's. OpenReliant makes them wider or narrower (FreeType's `FT_Outline_Embolden`) until its digits and letters, drawn at eight times the size with capitals of the same height, cover as many pixels as the bitmap font's. The diff --git a/docs/guide/modding.md b/docs/guide/modding.md index d86b6b74..64665255 100644 --- a/docs/guide/modding.md +++ b/docs/guide/modding.md @@ -621,17 +621,26 @@ files, as is the loadout's backdrop. To replace one, add a PNG with its name, su bars beyond it. - **Opaque.** Pictures are drawn without their alpha channel, like the original pictures. -A few TGA pictures are read pixel by pixel at their original size. A PNG replaces one of these only -if it has the same size; otherwise it's skipped, with a message in the log -([#509](https://github.com/OpenReliant/openreliant/issues/509)): +Two TGA pictures are read pixel by pixel as data, one pixel for each star or for each colour of +the sky. A PNG replaces one of these only if it has the same size; otherwise it's skipped, with a +message in the log: | Picture | Size | What it is | |---|---|---| -| `fpanels.tga` | 256x256 | The loadout's panels | -| `powerball.tga` | 256x256 | The flight display's power ball | | `space.tga` | 360x360 | The star map | | `starref12.tga` | 256x256 | The sky colours | +Two more are drawn pixel by pixel at their picture's size. A PNG for one of these can be the +original's size or a whole multiple of it, the same across and down, up to 8192 pixels; another +size is skipped, with a message in the log. At 1024x1024, `fpanels.png` draws the loadout's panels +four times as sharp, with their text in the outline fonts ([Fonts](#fonts)), and `powerball.png` +draws the flight display's power ball four times as sharp. + +| Picture | Size | What it is | +|---|---|---| +| `fpanels.tga` | 256x256 | The loadout's panels, the buttons and the scrollers | +| `powerball.tga` | 256x256 | The flight display's power ball | + **Improvement:** the original only draws its interface pictures at their original size, and stretches its backgrounds to fit the screen. @@ -649,7 +658,8 @@ fonts](../formats/fnt.md#outline-fonts)): | `itacbig.fnt` | The ITAC's large text, and the titles of the CD player and the simulator pod | | `itacsml.fnt` | The ITAC's small text | | `blufont.fnt` | The flight display's text: readouts, clock, windows and objectives | -| `ld_handel.fnt` | The loadout's tooltip | +| `ld_handel.fnt` | The loadout's tooltip, and the titles and ship names on its panels | +| `handels.fnt` | The figures on the loadout's info panel | | `font_01.fnt` | The developers' text on the main menu | - **Layout.** The game lays out text using the bitmap font's character widths, which don't change. @@ -661,6 +671,9 @@ fonts](../formats/fnt.md#outline-fonts)): - **Colour.** The flight display's font and the loadout tooltip's font are drawn through palettes. A mod's font for them is drawn in the colour of the bitmap font's letters, and glyphs the bitmap font draws in other colours, such as symbols, keep their bitmaps. +- **The loadout's panels.** The panels keep their bitmap text at the original's size. With a larger + `fpanels.png` ([Pictures](#pictures)), their text is drawn from the outline fonts at the panels' + size instead. - **Weight and outline.** A mod's font is drawn at its normal weight. Each character gets a black outline one bitmap pixel wide, like the text in the original. - **Built in.** OpenReliant draws all text except the developers' text with Newtown, a public domain @@ -1454,6 +1467,7 @@ A script's messages and errors follow its mod's name, as above; | `skipping the mod : checking failed: ...` | The archive doesn't match its checksum: download it again, or pack it again with `--checksum` ([Checksums](#checksums)) | | `: mod.ini: the '' ...` | The entry the message names, and what it says is wrong ([New ships, guns, missiles and pilots](#new-ships-guns-missiles-and-pilots), [Game variables](#game-variables)) | | `skipping the picture that replaces : it's , and the original is ` | That picture must keep the original's size ([Pictures](#pictures)) | +| `skipping the picture that replaces : it's , and it must be a whole multiple of the original's , at most 8192 pixels across` | That picture must be the original's size or a whole multiple of it ([Pictures](#pictures)) | | `: shape is cut short: its rows run past the end of its block, so it draws nothing` | The set's file is broken at that shape: make the set again, or give a picture in its place ([Shapes](#shapes)) | | `the of is left out: it is and its picture ` | A map must be the same size as its texture ([Material maps](#material-maps)) | | `the of is left out: it has mipmap levels and its picture ` | Save the DDS or KTX2 map with all its mipmaps, as many as the picture has ([Compression](#compression)) | diff --git a/docs/guide/what-mods-can-do.md b/docs/guide/what-mods-can-do.md index 8b3f4d9c..0445cb16 100644 --- a/docs/guide/what-mods-can-do.md +++ b/docs/guide/what-mods-can-do.md @@ -28,7 +28,7 @@ the mods screen ([The mods screen](modding.md#the-mods-screen)). | Give surfaces normal, material and glow maps, lit as today's games light them | [Material maps](modding.md#material-maps) | | | Draw the ships differently in the loadout | [Textures in the loadout](modding.md#textures-in-the-loadout) | | | Replace the interface's shapes, sharp at any window size | [Shapes](modding.md#shapes) | | -| Replace the screens' backgrounds, widescreen ones included | [Pictures](modding.md#pictures) | | +| Replace the screens' backgrounds, widescreen ones included, and the loadout's panels and the power ball at a multiple of their size | [Pictures](modding.md#pictures) | | | Replace the interface's fonts with TrueType or OpenType fonts | [Fonts](modding.md#fonts) | | | Change how surfaces are lit, with GLSL surface and lighting functions | [Surface and lighting functions](scripting.md#surface-and-lighting-functions) | [`cel-shading`](../../examples/mods/cel-shading) | | Draw post effects over the frame | [Post effects](scripting.md#post-effects) | [`crt`](../../examples/mods/crt) | @@ -122,9 +122,7 @@ These are planned, each in an issue: - Campaigns of more than 28 missions ([#987](https://github.com/OpenReliant/openreliant/issues/987)) - Post effects that read the scene's depth ([#633](https://github.com/OpenReliant/openreliant/issues/633)) - KTX2 files with Basis Universal data ([#637](https://github.com/OpenReliant/openreliant/issues/637)) -- The loadout's panels and the power ball at any size - ([#509](https://github.com/OpenReliant/openreliant/issues/509)) -- Outline fonts for the loadout panels' text and the flight display's small fonts +- Outline fonts for the flight display's small fonts ([#520](https://github.com/OpenReliant/openreliant/issues/520)) - The game's models exported with all their detail levels and parts ([#697](https://github.com/OpenReliant/openreliant/issues/697)) diff --git a/src/engine/game/hud.zig b/src/engine/game/hud.zig index dd182613..83f7112e 100644 --- a/src/engine/game/hud.zig +++ b/src/engine/game/hud.zig @@ -1584,16 +1584,81 @@ pub fn textLeft(opened: Opened, x: i32, text: []const u8, alignment: Align, scal /// `size` pixels, four bytes a pixel (red, green, blue and alpha), rows from the top. The pane is /// the whole image, and what is drawn past its edges is lost. The loadout writes its panels' text /// into their textures this way, where the display draws its own through the device (`drawText`). +/// +/// **Improvement:** the image can be a whole multiple of the original's size, `scale` times as +/// large across and down, as the loadout's panels are for a mod's larger picture. What is drawn +/// is placed in the original's pixels, and each of those is a square of the pane's; text is drawn +/// from the outline font that stands in for its font, where there is one (`drawTextInto`). pub const Pane = struct { rgba: [][4]u8, size: [2]u32, + /// How many of its pixels across and down stand for each of the original's. + scale: u32 = 1, - /// Sets the pixel at `at` to `colour`, opaque, where it lies in the pane. + /// Sets the pixel at `at`, in the original's pixels, to `colour`, opaque, where it lies in the + /// pane: the square of the pane's pixels that stands for it. pub fn plot(pane: Pane, at: [2]i32, colour: [3]u8) void { + const x = std.math.cast(u32, at[0]) orelse return; + const y = std.math.cast(u32, at[1]) orelse return; + if (x >= pane.size[0] / pane.scale or y >= pane.size[1] / pane.scale) return; + for (0..pane.scale) |down| { + const row = (@as(usize, y) * pane.scale + down) * pane.size[0] + @as(usize, x) * pane.scale; + @memset(pane.rgba[row..][0..pane.scale], .{ colour[0], colour[1], colour[2], opaque_alpha }); + } + } + + /// The outline font that stands in for `opened` at the pane's size; none for a font without + /// one, and none in a pane of the original's size, which keeps the bitmap glyphs as the game + /// draws them. + fn outlineOf(pane: Pane, opened: *const Opened) Allocator.Error!?outline.Sized { + if (pane.scale == 1) return null; + const shown = opened.outline orelse return null; + return shown.at(@floatFromInt(pane.scale)); + } + + /// Where character `code` of a line is drawn when its place on the line starts at `at`, in the + /// original's pixels: from `outlined`, the outline font at the pane's size, or as its bitmap + /// glyph where there is none, the outline font has none for it, or it keeps its own colours. + fn glyphPlacing(pane: Pane, opened: *const Opened, outlined: ?outline.Sized, code: u8, at: [2]f32) outline.Placing { + const glyphs = outlined orelse return .bitmap; + if (opened.own_colours.isSet(code)) return .bitmap; + const scale: f32 = @floatFromInt(pane.scale); + return glyphs.glyphAt(code, at[0] * scale, at[1] * scale, scale); + } + + /// The outline glyph `glyph` of `atlas`, with its top left corner at `corner` in the pane's + /// own pixels, rounded, in `colour` as far as it covers each pixel (`blend`). + fn blendGlyph(pane: Pane, atlas: *const outline.Atlas, glyph: outline.Atlas.Placed, corner: [2]f32, colour: [3]u8) void { + const across = atlas.image.width(); + const left: i64 = @intFromFloat(@round(corner[0])); + const top: i64 = @intFromFloat(@round(corner[1])); + for (0..glyph.height) |row| { + for (0..glyph.width) |column| { + const cover = atlas.pixels[((glyph.at[1] + row) * across + glyph.at[0] + column) * 4 + 3]; + if (cover != 0) pane.blend(.{ left + @as(i64, @intCast(column)), top + @as(i64, @intCast(row)) }, colour, cover); + } + } + } + + /// Lays `colour` over the pixel at `at`, in the pane's own pixels, as far as `cover` says, from + /// 0 for none to 255 for all, as alpha blending lays one colour over another; nothing past the + /// pane's edges. + fn blend(pane: Pane, at: [2]i64, colour: [3]u8, cover: u8) void { const x = std.math.cast(u32, at[0]) orelse return; const y = std.math.cast(u32, at[1]) orelse return; if (x >= pane.size[0] or y >= pane.size[1]) return; - pane.rgba[@as(usize, y) * pane.size[0] + x] = .{ colour[0], colour[1], colour[2], opaque_alpha }; + const pixel = &pane.rgba[@as(usize, y) * pane.size[0] + x]; + const most: f32 = std.math.maxInt(u8); + const over = @as(f32, @floatFromInt(cover)) / most; + const under = @as(f32, @floatFromInt(pixel[3])) / most * (1 - over); + const alpha = over + under; + if (alpha == 0) return; + const from: @Vector(3, f32) = @floatFromInt(@as(@Vector(3, u8), pixel[0..3].*)); + const to: @Vector(3, f32) = @floatFromInt(@as(@Vector(3, u8), colour)); + const over_share: @Vector(3, f32) = @splat(over / alpha); + const under_share: @Vector(3, f32) = @splat(under / alpha); + const mixed: @Vector(3, u8) = @intFromFloat(@round(to * over_share + from * under_share)); + pixel.* = @as([3]u8, mixed) ++ .{@as(u8, @intFromFloat(@round(alpha * most)))}; } /// `VFX_line_draw` (`winvfx16.dll`, `0x10002849`) of a line along a row or a column, as the @@ -1639,6 +1704,21 @@ pub const Remap = struct { if (entry == clear) return null; return remap.palette[entry]; } + + /// The colour of its fullest level, which a glyph's strokes are drawn in; null where it leaves + /// that clear. + fn fullest(remap: Remap) ?[3]u8 { + return remap.colour(@intCast(remap.table.len -| 1)); + } + + /// The colour of the faintest level it draws past 0, which a glyph's strokes fade to at their + /// edges; null where it draws none. + fn faintest(remap: Remap) ?[3]u8 { + for (1..remap.table.len) |level| { + if (remap.colour(@intCast(level))) |found| return found; + } + return null; + } }; /// `VFX_character_draw` (`winvfx16.dll`, `0x10008819`) with a remap table: `glyph` with its top @@ -1656,15 +1736,38 @@ fn drawGlyphInto(pane: Pane, glyph: fnt.Glyph, at: [2]i32, remap: Remap) void { /// says, in `remap`'s colours, and returns where the line ends. The glyphs follow one another by /// their widths, as `VFX_string_draw` (`winvfx16.dll`, `0x10008A11`) moves along; a code past those /// `font_open` caches is left out, as `drawText` leaves it. -pub fn drawTextInto(pane: Pane, opened: *const Opened, at: [2]i32, text: []const u8, remap: Remap, alignment: Align) i32 { +/// +/// **Improvement:** in a pane larger than the original's (`Pane.scale`), the outline font that +/// stands in for the font draws its glyphs at the pane's size over the font's layout. A bitmap +/// glyph's strokes are drawn in the colour of the remap's fullest level and fade through the +/// others to its faintest at their edges, so an outline glyph is drawn in the fullest's colour, on +/// an edge in the faintest's, one of the original's pixels wide: the info panel's green text on a +/// dark edge, and the title's black letters edged in green. +pub fn drawTextInto(pane: Pane, opened: *const Opened, at: [2]i32, text: []const u8, remap: Remap, alignment: Align) Allocator.Error!i32 { if (text.len == 0) return at[0]; - var x = textLeft(opened.*, at[0], text, alignment, 1); - for (text) |code| { - if (code >= cached_codes) continue; - if (opened.font.glyph(code)) |glyph| drawGlyphInto(pane, glyph, .{ x, at[1] }, remap); - x += opened.widths[code]; - } - return x; + const left: f32 = @floatFromInt(textLeft(opened.*, at[0], text, alignment, 1)); + const top: f32 = @floatFromInt(at[1]); + const outlined = try pane.outlineOf(opened); + // The edge the outline glyphs stand on, first, under every glyph of the line (`edge_width`). + if (outlined) |glyphs| if (remap.faintest()) |edge| { + const reach = edge_width * @as(f32, @floatFromInt(pane.scale)); + var line: Line = .{ .opened = opened, .text = text, .scale = 1, .x = left }; + while (line.next()) |glyph| switch (pane.glyphPlacing(opened, outlined, glyph.code, .{ glyph.left, top })) { + .placed => |placed| for (edge_directions) |direction| { + const corner = placed.corner + @as(@Vector(2, f32), direction) * @as(@Vector(2, f32), @splat(reach)); + pane.blendGlyph(glyphs.atlas, placed.glyph, corner, edge); + }, + .blank, .bitmap => {}, + }; + }; + const ink = remap.fullest(); + var line: Line = .{ .opened = opened, .text = text, .scale = 1, .x = left }; + while (line.next()) |glyph| switch (pane.glyphPlacing(opened, outlined, glyph.code, .{ glyph.left, top })) { + .blank => {}, + .placed => |placed| if (ink) |colour| pane.blendGlyph(outlined.?.atlas, placed.glyph, placed.corner, colour), + .bitmap => if (opened.font.glyph(glyph.code)) |bitmap| drawGlyphInto(pane, bitmap, .{ @intFromFloat(glyph.left), at[1] }, remap), + }; + return @intFromFloat(line.x); } /// `hud_text_wrapped` (`0x00480FD0`) into `pane`: `text` broken into lines at most `width` pixels @@ -1680,12 +1783,12 @@ pub fn drawWrappedInto( width: i32, line_height: i32, max_lines: usize, -) usize { +) Allocator.Error!usize { var lines: WrappedText = .init(&opened.widths, text, width, max_lines); var taken: usize = 0; var y = at[1]; while (lines.next()) |line| : (y += line_height) { - _ = drawTextInto(pane, opened, .{ at[0], y }, line, remap, alignment); + _ = try drawTextInto(pane, opened, .{ at[0], y }, line, remap, alignment); taken += 1; } return taken; @@ -1729,7 +1832,7 @@ test drawTextInto { var rgba: [8 * 3][4]u8 = @splat(empty); const pane: Pane = .{ .rgba = &rgba, .size = .{ 8, 3 } }; // Each glyph after the last by its width; the line ends past the last. - try std.testing.expectEqual(7, drawTextInto(pane, &opened, .{ 1, 1 }, "A B", remap, .left)); + try std.testing.expectEqual(7, try drawTextInto(pane, &opened, .{ 1, 1 }, "A B", remap, .left)); const drawn = [_]struct { [2]u32, u8 }{ .{ .{ 2, 1 }, 0x4F }, .{ .{ 3, 1 }, 0x40 }, .{ .{ 1, 2 }, 0x41 }, .{ .{ 3, 2 }, 0x4F }, }; @@ -1748,17 +1851,78 @@ test drawTextInto { // Centred, half the width comes off; to the right, all of it. A glyph cut by the pane's edge // keeps the part inside: the `A`'s level 2 falls off to the left. rgba = @splat(empty); - try std.testing.expectEqual(3, drawTextInto(pane, &opened, .{ 1, 0 }, "AB", remap, .centre)); + try std.testing.expectEqual(3, try drawTextInto(pane, &opened, .{ 1, 0 }, "AB", remap, .centre)); try std.testing.expectEqual([4]u8{ 0x4F, 0xB0, 7, 255 }, rgba[0]); try std.testing.expectEqual([4]u8{ 0x40, 0xBF, 7, 255 }, rgba[1]); try std.testing.expectEqual([4]u8{ 0x4F, 0xB0, 7, 255 }, rgba[8 + 1]); try std.testing.expectEqual(empty, rgba[8]); rgba = @splat(empty); - try std.testing.expectEqual(8, drawTextInto(pane, &opened, .{ 8, 0 }, "AA", remap, .right)); + try std.testing.expectEqual(8, try drawTextInto(pane, &opened, .{ 8, 0 }, "AA", remap, .right)); try std.testing.expectEqual([4]u8{ 0x4F, 0xB0, 7, 255 }, rgba[3]); try std.testing.expectEqual([4]u8{ 0x41, 0xBE, 7, 255 }, rgba[8 + 5]); // Nothing to draw leaves the line where it starts. - try std.testing.expectEqual(5, drawTextInto(pane, &opened, .{ 5, 0 }, "", remap, .right)); + try std.testing.expectEqual(5, try drawTextInto(pane, &opened, .{ 5, 0 }, "", remap, .right)); +} + +test "text in a pane larger than the original's" { + const gpa = std.testing.allocator; + const palette = paneTestPalette(); + // Level 1, the faintest, to entry 0x40, and level 15, the fullest, to entry 0x4F. + var table: [16]u8 = @splat(Remap.clear); + table[1] = 0x40; + table[15] = 0x4F; + const remap: Remap = .{ .table = &table, .palette = &palette }; + const ink: [4]u8 = .{ 0x4F, 0xB0, 7, 255 }; + const edge: [4]u8 = .{ 0x40, 0xBF, 7, 255 }; + const empty: [4]u8 = .{ 0, 0, 0, 0 }; + const width = 24; + var rgba: [width * 16][4]u8 = @splat(empty); + const pane: Pane = .{ .rgba = &rgba, .size = .{ width, 16 }, .scale = 2 }; + + // Without an outline font, each of the bitmap's pixels is a square of two by two. + var opened: Opened = .open(try fnt.Font.parse(outline.testing.font), null); + defer opened.deinit(gpa); + try std.testing.expectEqual(12, try drawTextInto(pane, &opened, .{ 0, 0 }, "H#", remap, .left)); + for ([_][2]usize{ .{ 2, 4 }, .{ 3, 5 }, .{ 9, 13 }, .{ 14, 4 }, .{ 21, 13 } }) |at| { + try std.testing.expectEqual(ink, rgba[at[1] * width + at[0]]); + } + try std.testing.expectEqual(empty, rgba[3 * width + 2]); + try std.testing.expectEqual(empty, rgba[4 * width + 1]); + + // With one, H is drawn from it at the pane's size, on an edge two pixels wide, and `#`, which + // it has no glyph for, keeps its bitmap. + var boxes: outline.testing.Boxes = .{}; + const rasterizer = boxes.rasterizer(); + const face = rasterizer.open("face").?; + var shown: outline.Outline = .{ .gpa = gpa, .rasterizer = rasterizer, .face = face, .file = null, .fit = (try outline.Fit.of(rasterizer, face, opened.font, &outline.level_cover, .bitmap, gpa)).? }; + defer shown.deinit(); + opened.outline = &shown; + rgba = @splat(empty); + try std.testing.expectEqual(12, try drawTextInto(pane, &opened, .{ 0, 0 }, "H#", remap, .left)); + const h = (try shown.at(2)).?.glyphAt('H', 0, 0, 2).placed; + const left: usize = @intFromFloat(h.corner[0]); + const top: usize = @intFromFloat(h.corner[1]); + try std.testing.expectEqual(ink, rgba[top * width + left]); + try std.testing.expectEqual(ink, rgba[(top + h.glyph.height - 1) * width + left + h.glyph.width - 1]); + // The edge reaches two pixels out across and down, and the diagonals' 1.4 round to one. + const right = left + h.glyph.width - 1; + const bottom = top + h.glyph.height - 1; + for ([_][2]usize{ .{ left, top - 1 }, .{ left, top - 2 }, .{ left - 2, top }, .{ right + 2, bottom }, .{ right + 1, bottom + 1 } }) |at| { + try std.testing.expectEqual(edge, rgba[at[1] * width + at[0]]); + } + try std.testing.expectEqual(empty, rgba[(top - 3) * width + left]); + try std.testing.expectEqual(empty, rgba[(bottom + 2) * width + right + 2]); + try std.testing.expectEqual(ink, rgba[4 * width + 14]); + + // An outline glyph's edges blend with what lies under them: half covered over clear, its + // colour half opaque, and over the edge, the two mixed. + var pixel = [1][4]u8{empty}; + const one: Pane = .{ .rgba = &pixel, .size = .{ 1, 1 } }; + one.blend(.{ 0, 0 }, .{ 200, 100, 0 }, 128); + try std.testing.expectEqual([4]u8{ 200, 100, 0, 128 }, pixel[0]); + pixel[0] = .{ 0, 0, 0, 255 }; + one.blend(.{ 0, 0 }, .{ 200, 100, 0 }, 128); + try std.testing.expectEqual([4]u8{ 100, 50, 0, 255 }, pixel[0]); } test drawWrappedInto { @@ -1773,14 +1937,14 @@ test drawWrappedInto { const pane: Pane = .{ .rgba = &rgba, .size = .{ 8, 8 } }; // "AA AA" is 14 wide: at 7 a line, it breaks at the space, the second line 3 below the first. - try std.testing.expectEqual(2, drawWrappedInto(pane, &opened, .{ 0, 1 }, "AA AA", remap, .left, 7, 3, 3)); + try std.testing.expectEqual(2, try drawWrappedInto(pane, &opened, .{ 0, 1 }, "AA AA", remap, .left, 7, 3, 3)); for ([_][2]u32{ .{ 1, 1 }, .{ 4, 1 }, .{ 1, 4 }, .{ 4, 4 } }) |at| { try std.testing.expectEqual([4]u8{ 0x4F, 0xB0, 7, 255 }, rgba[at[1] * 8 + at[0]]); } // A line feed last takes a line with nothing in it, and no more lines are drawn than allowed. - try std.testing.expectEqual(2, drawWrappedInto(pane, &opened, .{ 0, 0 }, "A\n", remap, .left, 7, 3, 3)); - try std.testing.expectEqual(1, drawWrappedInto(pane, &opened, .{ 0, 0 }, "AA AA", remap, .left, 7, 3, 1)); - try std.testing.expectEqual(0, drawWrappedInto(pane, &opened, .{ 0, 0 }, "", remap, .left, 7, 3, 3)); + try std.testing.expectEqual(2, try drawWrappedInto(pane, &opened, .{ 0, 0 }, "A\n", remap, .left, 7, 3, 3)); + try std.testing.expectEqual(1, try drawWrappedInto(pane, &opened, .{ 0, 0 }, "AA AA", remap, .left, 7, 3, 1)); + try std.testing.expectEqual(0, try drawWrappedInto(pane, &opened, .{ 0, 0 }, "", remap, .left, 7, 3, 3)); } test place { @@ -2121,7 +2285,7 @@ pub const Resources = struct { errdefer closeFont(&small, gpa); var new = try openFont(gpa, archive, TargetFonts.new_name, global, null); errdefer closeFont(&new, gpa); - const picture = try matmanager.readPixels(gpa, archive, power.picture_name); + const picture = try matmanager.readPixels(gpa, archive, power.picture_name, .multiples); defer picture.deinit(gpa); const ball: *power.Ball = try .create(gpa, picture); errdefer ball.destroy(gpa); diff --git a/src/engine/game/hud/outline.zig b/src/engine/game/hud/outline.zig index e4b74bed..144ee2b0 100644 --- a/src/engine/game/hud/outline.zig +++ b/src/engine/game/hud/outline.zig @@ -37,8 +37,8 @@ const log = std.log.scoped(.fonts); pub const newtown = @embedFile("Newtown.ttf"); /// The game's Handel Gothic fonts, which Newtown replaces unless a mod has a font for them: the -/// menu and ITAC fonts, drawn in one colour, and the flight display and loadout tooltip fonts, -/// drawn through palettes. +/// menu and ITAC fonts, drawn in one colour, and the flight display and loadout fonts, drawn +/// through palettes. pub const handel_gothic = [_][]const u8{ hud.large_menu_font, hud.small_menu_font, @@ -46,6 +46,7 @@ pub const handel_gothic = [_][]const u8{ itac.small_font_name, hud.Resources.font_name, loadout.title_font_name, + loadout.stats_font_name, }; /// Whether Newtown replaces the game's font `name` (`handel_gothic`), ignoring folders and case, as @@ -304,6 +305,16 @@ fn inkSpan(cover: *const Cover, pixels: []const u8, width: usize, axis: Axis) ?[ return found; } +/// Where a character's glyph is drawn from an outline font's atlas. +pub const Placing = union(enum) { + /// Nothing: its glyph has no ink. + blank, + /// The bitmap glyph, since the outline font has none for it. + bitmap, + /// Its glyph in the atlas, and the pixel its top left corner falls on. + placed: struct { glyph: Atlas.Placed, corner: [2]f32 }, +}; + /// How a character is drawn from an outline font. pub const Shown = union(enum) { /// Nothing: its glyph has no ink. @@ -470,21 +481,32 @@ pub const Sized = struct { outline: *const Outline, atlas: *Atlas, - /// How character `code` is drawn when its place on the line starts at `left`, the line's top is - /// at `top`, and the bitmap font is drawn at `scale` times its size: with its ink centred on - /// the bitmap glyph's ink, on the bitmap font's baseline, and its pixels aligned to the + /// Where character `code` is drawn when its place on the line starts at `left`, the line's top + /// is at `top`, and the bitmap font is drawn at `scale` times its size: with its ink centred + /// on the bitmap glyph's ink, on the bitmap font's baseline, and its pixels aligned to the /// window's. - pub fn shown(sized: Sized, code: u8, left: f32, top: f32, scale: f32) Shown { - const atlas = sized.atlas; + pub fn glyphAt(sized: Sized, code: u8, left: f32, top: f32, scale: f32) Placing { const fit = &sized.outline.fit; - const glyph = switch (atlas.glyphs[code]) { + const glyph = switch (sized.atlas.glyphs[code]) { .blank => return .blank, .bitmap => return .bitmap, .placed => |placed| placed, }; const middle = fit.middles[code] orelse return .blank; - const x = @round(left + middle * scale - glyph.middle); - const y = @round(top + fit.baseline * scale) - @as(f32, @floatFromInt(glyph.top)); + return .{ .placed = .{ .glyph = glyph, .corner = .{ + @round(left + middle * scale - glyph.middle), + @round(top + fit.baseline * scale) - @as(f32, @floatFromInt(glyph.top)), + } } }; + } + + /// How character `code` is drawn (`glyphAt`) over the window. + pub fn shown(sized: Sized, code: u8, left: f32, top: f32, scale: f32) Shown { + const atlas = sized.atlas; + const glyph, const x, const y = switch (sized.glyphAt(code, left, top, scale)) { + .blank => return .blank, + .bitmap => return .bitmap, + .placed => |placed| .{ placed.glyph, placed.corner[0], placed.corner[1] }, + }; const width: f32 = @floatFromInt(glyph.width); const height: f32 = @floatFromInt(glyph.height); const across: f32 = @floatFromInt(atlas.image.width()); diff --git a/src/engine/game/hud/power.zig b/src/engine/game/hud/power.zig index 54889cde..badf9bfa 100644 --- a/src/engine/game/hud/power.zig +++ b/src/engine/game/hud/power.zig @@ -6,6 +6,10 @@ //! texture scrolls with the power setting, so the ball looks as if it turns toward wherever the //! power is. The game writes it into the display a pixel at a time; OpenReliant writes the same //! pixels into an image each frame and draws that. +//! +//! **Improvement:** a mod's picture can be a whole multiple of the original's size +//! (`matmanager.Sizes.multiples`), and the ball is drawn with as many pixels for each of the +//! original's, as sharp as its picture. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -54,9 +58,16 @@ const sphere_squared: f32 = 2; const half_turn_texels: f32 = 138; /// What `hud_init` takes off each point of the sphere's offset into the texture, a number in its -/// code: in 16 bits, the same as adding `0x8080`, the texture's middle, row 128 and column 128. +/// code: in 16 bits, the same as adding `0x8080`, the texture's middle, row 128 and column 128 +/// (`textureMiddle`). const middle_offset = 0x7F80; +/// The offset of the middle of a texture `side` texels across and down: its middle row, then its +/// middle column. +fn textureMiddle(side: u32) u32 { + return side / 2 * side + side / 2; +} + /// How `fillShade` lights the ball: each pixel stands for the point `shade_shift` further along /// either axis on a sphere of radius 1 (`0x004DC4C0`), so that the brightest spot is up and to the /// left of the middle; the light stands `light_distance` in front (a number in `hud_init`'s code); @@ -79,35 +90,57 @@ comptime { // The tables' fractions are the game's own constants, worked out from the ball's sizes. std.debug.assert(step == 1.0 / 31.0); std.debug.assert(light_share == 1.0 / 63.0); + // Wrapped round the texture as 16 bits wrap, taking `middle_offset` off adds the middle. + std.debug.assert(@mod(-middle_offset, texture_size * texture_size) == textureMiddle(texture_size)); + std.debug.assert(stepAt(1) == step); } -/// The tables the ball is drawn from, and the image OpenReliant draws it into. +/// The tables the ball is drawn from, and the image OpenReliant draws it into. The game's are +/// `size` pixels across and down, and its texture `texture_size`; a mod's picture can make them +/// `multiple` times as large. pub const Ball = struct { + /// How many of the ball's pixels stand for each of the original's, across and down: its + /// picture's size over `texture_size`, 1 for the game's own. + multiple: u32, /// `powerball.tga`'s levels, a byte a pixel, top row first (`0x00569984`). The image is grey, /// and `hud_init` keeps the top 5 bits of each pixel's first channel. - texture: [texture_size * texture_size]u8, - /// Where each pixel of the ball shows the texture: a sphere mapped onto it, an offset from the - /// texture's middle, 16 bits wide (`0x00579E2C`). - sphere: [size * size]u16, + texture: []u8, + /// Where each pixel of the ball shows the texture: a sphere mapped onto it, an offset into + /// the texture that wraps round its end, as the game's 16 bits wrap round its 256 by 256 + /// texels (`0x00579E2C`). + sphere: []u32, /// How brightly each pixel is lit, 0 to 63 (`0x00567F90`). - shade: [size * size]u8, + shade: []u8, /// The colours: for each level of light, one for each of the texture's levels (`0x00566F90`, /// in the display's 16-bit format, and `0x00568E94` for the 8-bit one). colours: [lights][levels][3]u8, /// The ball, drawn afresh each frame, and the image the display draws it with. - pixels: [image_width * size * 4]u8, + pixels: []u8, level: [1]srtexture.Level, image: srtexture.Image, - /// What `hud_init` works out from `powerball.tga`. + /// What `hud_init` works out from `powerball.tga`, `picture`, at as many times the original's + /// size as the picture is (`multiple`). pub fn create(gpa: Allocator, picture: tga.Image) Allocator.Error!*Ball { + const multiple: u32 = @max(1, picture.width / texture_size); const ball = try gpa.create(Ball); - fillTexture(&ball.texture, picture); - fillSphere(&ball.sphere); - const left_over = fillShade(&ball.shade); - fillColours(&ball.colours, left_over); - @memset(&ball.pixels, 0); - ball.level = .{.{ .width = image_width, .height = size, .texels = &ball.pixels }}; + errdefer gpa.destroy(ball); + const side = texture_size * @as(usize, multiple); + const ball_side = size * @as(usize, multiple); + const texture = try gpa.alloc(u8, side * side); + errdefer gpa.free(texture); + const sphere = try gpa.alloc(u32, ball_side * ball_side); + errdefer gpa.free(sphere); + const shade = try gpa.alloc(u8, ball_side * ball_side); + errdefer gpa.free(shade); + const pixels = try gpa.alloc(u8, image_width * multiple * ball_side * 4); + ball.* = .{ .multiple = multiple, .texture = texture, .sphere = sphere, .shade = shade, .colours = undefined, .pixels = pixels, .level = undefined, .image = undefined }; + fillTexture(ball.texture, side, picture); + fillSphere(ball.sphere, multiple); + fillShade(ball.shade, multiple); + fillColours(&ball.colours, leftOver()); + @memset(ball.pixels, 0); + ball.level = .{.{ .width = image_width * multiple, .height = size * multiple, .texels = ball.pixels }}; ball.image = .{ .levels = &ball.level }; return ball; } @@ -115,6 +148,8 @@ pub const Ball = struct { /// Frees the ball, once its image has handed its texture back to the device. pub fn destroy(ball: *Ball, gpa: Allocator) void { ball.image.releaseTexture(); + for ([_][]u8{ ball.texture, ball.shade, ball.pixels }) |table| gpa.free(table); + gpa.free(ball.sphere); gpa.destroy(ball); } @@ -122,18 +157,27 @@ pub const Ball = struct { /// of the circle, the texture scrolled by half the setting, lit by `shade` and coloured by /// `colours`. While `hit_shake` is above zero, each row moves right by a random share of /// `10 * hit_shake` pixels, a number of `random` a row, as the game does in 16-bit colour. + /// At a larger `multiple`, the scroll and the shake are as many times larger, and each shake + /// moves the rows that stand for one of the original's. pub fn render(ball: *Ball, setting: [2]f32, hit_shake: f32, random: ?*Random) void { - @memset(&ball.pixels, 0); - const scroll = std.math.lossyCast(i32, setting[0] * 0.5) - std.math.lossyCast(i32, setting[1] * -0.5) * texture_size; - var row: i32 = -radius; - while (row < radius) : (row += 1) { - const span = std.math.lossyCast(i32, @sqrt(@as(f32, @floatFromInt(radius * radius - row * row))) + 0.5); - const jitter = @min(hud.rowShift(hit_shake, random), most_jitter); - const first: usize = @intCast((row + radius) * size + radius - span); - const into: usize = @intCast((row + radius) * image_width + radius - span + jitter); + @memset(ball.pixels, 0); + const multiple: i32 = @intCast(ball.multiple); + const scale: f32 = @floatFromInt(ball.multiple); + const side = texture_size * @as(i64, multiple); + const ball_radius = radius * multiple; + const ball_side = size * multiple; + const width = image_width * multiple; + const scroll = std.math.lossyCast(i32, setting[0] * 0.5 * scale) - std.math.lossyCast(i32, setting[1] * -0.5 * scale) * side; + var jitter: i32 = 0; + var row: i32 = -ball_radius; + while (row < ball_radius) : (row += 1) { + const span = std.math.lossyCast(i32, @sqrt(@as(f32, @floatFromInt(ball_radius * ball_radius - row * row))) + 0.5); + if (@mod(row + ball_radius, multiple) == 0) jitter = @min(hud.rowShift(hit_shake, random), most_jitter) * multiple; + const first: usize = @intCast((row + ball_radius) * ball_side + ball_radius - span); + const into: usize = @intCast((row + ball_radius) * width + ball_radius - span + jitter); for (0..@intCast(2 * span)) |across| { const at = first + across; - const texel = ball.texture[@intCast(std.math.clamp(@as(i32, ball.sphere[at]) + scroll, 0, texture_size * texture_size - 1))]; + const texel = ball.texture[@intCast(std.math.clamp(@as(i64, ball.sphere[at]) + scroll, 0, side * side - 1))]; const colour = ball.colours[ball.shade[at]][texel]; const pixel = ball.pixels[(into + across) * 4 ..][0..4]; pixel.* = .{ colour[0], colour[1], colour[2], 255 }; @@ -149,56 +193,71 @@ fn channel(x: f32) u8 { return @intCast(std.math.clamp(std.math.lossyCast(i32, x), 0, 255)); } -fn fillTexture(texture: *[texture_size * texture_size]u8, picture: tga.Image) void { - for (0..texture_size) |y| { - for (0..texture_size) |x| { +/// The texture, `side` texels across and down, from `picture`; a texel past the picture's edges is +/// black. +fn fillTexture(texture: []u8, side: usize, picture: tga.Image) void { + for (0..side) |y| { + for (0..side) |x| { const grey = if (x < picture.width and y < picture.height) picture.pixel(x, y)[0] else 0; - texture[y * texture_size + x] = grey >> 3; + texture[y * side + x] = grey >> 3; } } } /// Each pixel of the ball as a point on a sphere of radius √2 seen from the front, and where it /// falls on the texture: `half_turn_texels` to the half turn from its middle, `x` to the right and -/// `y` down. A pixel outside the circle is pulled in toward it. -fn fillSphere(sphere: *[size * size]u16) void { - for (0..size) |row| { - const y = centred(row) * step; +/// `y` down. A pixel outside the circle is pulled in toward it. At a larger `multiple`, the ball +/// and the texture both are as many times larger. +fn fillSphere(sphere: []u32, multiple: u32) void { + const ball_side = size * multiple; + const side: i64 = texture_size * multiple; + const ball_step = stepAt(multiple); + const half_turn = half_turn_texels * @as(f32, @floatFromInt(multiple)); + for (0..ball_side) |row| { + const y = centred(row, multiple) * ball_step; const y_squared = y * y; - for (0..size) |column| { - const x = centred(column) * step; + for (0..ball_side) |column| { + const x = centred(column, multiple) * ball_step; const at = inside(x, y, x * x + y_squared); const z = @sqrt(sphere_squared - at.squared); const across: f32 = std.math.asin(at.x / z); - const down = std.math.lossyCast(i32, std.math.asin(at.y / z) * (1.0 / std.math.pi) * -half_turn_texels); - const offset = std.math.lossyCast(i32, across * (1.0 / std.math.pi) * half_turn_texels) - down * texture_size - middle_offset; - sphere[row * size + column] = @truncate(@as(u32, @bitCast(offset))); + const down = std.math.lossyCast(i32, std.math.asin(at.y / z) * (1.0 / std.math.pi) * -half_turn); + const offset = std.math.lossyCast(i32, across * (1.0 / std.math.pi) * half_turn) - down * side + textureMiddle(@intCast(side)); + sphere[row * ball_side + column] = @intCast(@mod(offset, side * side)); } } } -/// How much light each pixel of the ball gets, from 0 to 63: the pixel at `x`, `y` stands for the -/// point `(x + shade_shift, y + shade_shift)` on a sphere of radius 1, so the brightest spot is up -/// and to the left of the middle, and gets `shade_scale` times the cosine of the angle between the -/// sphere's surface there and a light `light_distance` in front. Returns the `x` of the last -/// pixel's point, which the colours start from. -fn fillShade(shade: *[size * size]u8) f32 { +/// How much light each pixel of the ball gets, from 0 to 63: the point it stands for +/// (`shadePoint`) gets `shade_scale` times the cosine of the angle between the sphere's surface +/// there and a light `light_distance` in front. +fn fillShade(shade: []u8, multiple: u32) void { const light: math.Vector = .{ 0, 0, light_distance }; - var left_over: f32 = 0; - for (0..size) |row| { - const y = centred(row) * step + shade_shift; - const y_squared = y * y; - for (0..size) |column| { - const x = centred(column) * step + shade_shift; - const at = inside(x, y, x * x + y_squared); + const ball_side = size * multiple; + for (0..ball_side) |row| { + for (0..ball_side) |column| { + const at = shadePoint(row, column, multiple); const point: math.Vector = .{ at.x, at.y, @sqrt(1 - at.squared) }; const towards = light - point; const lit = math.dot(towards, point) * shade_scale / math.length(towards); - shade[row * size + column] = @intCast(std.math.clamp(std.math.lossyCast(i32, lit), 0, lights - 1)); - left_over = at.x; + shade[row * ball_side + column] = @intCast(std.math.clamp(std.math.lossyCast(i32, lit), 0, lights - 1)); } } - return left_over; +} + +/// The point the pixel at `row` and `column` stands for, `(x + shade_shift, y + shade_shift)` on a +/// sphere of radius 1, so that the brightest spot is up and to the left of the middle. +fn shadePoint(row: usize, column: usize, multiple: u32) Inside { + const ball_step = stepAt(multiple); + const y = centred(row, multiple) * ball_step + shade_shift; + const x = centred(column, multiple) * ball_step + shade_shift; + return inside(x, y, x * x + y * y); +} + +/// What `hud_init` has left in the highlight's place from working out the shade, which the colours +/// start from (`fillColours`): the `x` of the last pixel's point at the original's size. +fn leftOver() f32 { + return shadePoint(size - 1, size - 1, 1).x; } /// The colours: orange shades of the texture's level, brighter with the light, and past three @@ -221,9 +280,14 @@ fn fillColours(colours: *[lights][levels][3]u8, left_over: f32) void { } } -/// A row or column of the ball, from its middle. -fn centred(index: usize) f32 { - return @floatFromInt(@as(i32, @intCast(index)) - radius); +/// A row or column of the ball, from its middle, for a ball `multiple` times the original's size. +fn centred(index: usize, multiple: u32) f32 { + return @floatFromInt(@as(i64, @intCast(index)) - radius * @as(i64, multiple)); +} + +/// A step across a ball `multiple` times the original's size: `step` for the original's. +fn stepAt(multiple: u32) f32 { + return 1.0 / @as(f32, @floatFromInt(radius * multiple)); } const Inside = struct { x: f32, y: f32, squared: f32 }; @@ -375,7 +439,7 @@ test Ball { try std.testing.expectEqual(128 >> 3, ball.texture[0x8080]); // The light is brightest above and to the left of the middle, and none reaches the far edge. try std.testing.expect(ball.shade[20 * size + 20] > ball.shade[40 * size + 40]); - try std.testing.expectEqual(lights - 1, std.mem.max(u8, &ball.shade)); + try std.testing.expectEqual(lights - 1, std.mem.max(u8, ball.shade)); // Dark and unlit is black; the brightest light adds a white highlight. try std.testing.expectEqual([3]u8{ 0, 0, 0 }, ball.colours[0][0]); try std.testing.expect(ball.colours[lights - 1][0][2] > 0); @@ -393,6 +457,61 @@ test Ball { try std.testing.expect(!std.mem.eql(u8, &before, ball.pixels[middle..][0..3])); } +test "a larger picture draws a larger ball" { + const gpa = std.testing.allocator; + const picture = try testing.picture(gpa); + defer gpa.free(picture.rgb); + const ball = try Ball.create(gpa, picture); + defer ball.destroy(gpa); + // The same gradient, twice as large. + const rgb = try gpa.alloc(u8, 4 * texture_size * texture_size * 3); + defer gpa.free(rgb); + for (0..4 * texture_size * texture_size) |at| @memset(rgb[at * 3 ..][0..3], @intCast(at % (2 * texture_size) / 2)); + const large = try Ball.create(gpa, .{ .width = 2 * texture_size, .height = 2 * texture_size, .rgb = rgb }); + defer large.destroy(gpa); + try std.testing.expectEqual(2, large.multiple); + try std.testing.expectEqual(2 * image_width, large.image.width()); + try std.testing.expectEqual(2 * size, large.image.height()); + + // Its middle shows the texture's middle, and every other pixel stands for one of the + // original's, lit and coloured alike. + const large_side = 2 * size; + try std.testing.expectEqual(textureMiddle(2 * texture_size), large.sphere[size * large_side + size]); + try std.testing.expectEqual(ball.colours, large.colours); + for (0..size) |row| for (0..size) |column| { + try std.testing.expectEqual(ball.shade[row * size + column], large.shade[2 * row * large_side + 2 * column]); + }; + // Drawn, the middle of each shows the same level. + ball.render(.{ 1, 1 }, 0, null); + large.render(.{ 1, 1 }, 0, null); + const middle_pixel = (radius * image_width + radius) * 4; + const large_middle = (size * 2 * image_width + size) * 4; + try std.testing.expectEqualSlices(u8, ball.pixels[middle_pixel..][0..4], large.pixels[large_middle..][0..4]); + + // The shake moves the two rows that stand for one of the original's alike, twice as far. + var random: Random = .{}; + large.render(.{ 1, 1 }, 2, &random); + var moved = false; + for (0..size) |pair| { + var shifts: [2]?usize = @splat(null); + for (&shifts, 0..) |*shift, half| { + const row = 2 * pair + half; + const start = for (0..2 * image_width) |x| { + if (large.pixels[(row * 2 * image_width + x) * 4 + 3] != 0) break x; + } else continue; + const from_middle: i32 = @as(i32, @intCast(row)) - size; + const span = std.math.lossyCast(i32, @sqrt(@as(f32, @floatFromInt(size * size - from_middle * from_middle))) + 0.5); + shift.* = start - @as(usize, @intCast(size - span)); + } + // The top row is a single point, with no pixels. + const first = shifts[0] orelse continue; + try std.testing.expectEqual(first, shifts[1].?); + try std.testing.expect(first % 2 == 0); + if (first > 0) moved = true; + } + try std.testing.expect(moved); +} + test "the shake moves the ball's rows to the right" { const gpa = std.testing.allocator; const picture = try testing.picture(gpa); diff --git a/src/engine/game/matmanager.zig b/src/engine/game/matmanager.zig index 519d666a..843b8487 100644 --- a/src/engine/game/matmanager.zig +++ b/src/engine/game/matmanager.zig @@ -92,21 +92,62 @@ pub fn readImage(gpa: Allocator, archive: bigfile.Hog, picture_name: []const u8) } /// Reads and decodes the game's TGA picture `picture_name` from `archive` for its pixels, or the -/// mod picture that replaces it (`pictureName`), without its alpha channel, if it has the same -/// size. A mod picture of a different size is skipped, which the log says. +/// mod picture that replaces it (`pictureName`), without its alpha channel, if it has a size that +/// `sizes` allows. A mod picture of another size is skipped, which the log says. /// /// **Improvement:** the original only reads the TGA. -pub fn readPixels(gpa: Allocator, archive: bigfile.Hog, picture_name: []const u8) !tga.Image { +pub fn readPixels(gpa: Allocator, archive: bigfile.Hog, picture_name: []const u8, sizes: Sizes) !tga.Image { const own = try readTga(gpa, archive, picture_name); errdefer own.deinit(gpa); const read = try modPicture(gpa, archive, picture_name) orelse return own; defer read.deinit(gpa); - if (read.width != own.width or read.height != own.height) { - log.warn("skipping the picture that replaces {s}: it's {d}x{d}, and the original is {d}x{d}", .{ picture_name, read.width, read.height, own.width, own.height }); + if (!sizes.allows(.{ read.width, read.height }, .{ own.width, own.height })) { + switch (sizes) { + .original => log.warn("skipping the picture that replaces {s}: it's {d}x{d}, and the original is {d}x{d}", .{ picture_name, read.width, read.height, own.width, own.height }), + .multiples => log.warn("skipping the picture that replaces {s}: it's {d}x{d}, and it must be a whole multiple of the original's {d}x{d}, at most {d} pixels across", .{ picture_name, read.width, read.height, own.width, own.height, srtexture.max_side }), + } return own; } - for (0..@as(usize, own.width) * own.height) |at| own.rgb[at * 3 ..][0..3].* = read.rgba[at * 4 ..][0..3].*; - return own; + const pixels = @as(usize, read.width) * read.height; + const rgb = try gpa.alloc(u8, pixels * 3); + for (0..pixels) |at| rgb[at * 3 ..][0..3].* = read.rgba[at * 4 ..][0..3].*; + own.deinit(gpa); + return .{ .width = @intCast(read.width), .height = @intCast(read.height), .rgb = rgb }; +} + +/// The sizes a mod picture that `readPixels` reads can have. +pub const Sizes = enum { + /// The original's only, as what is drawn from the picture is made for that size. + original, + /// The original's or a whole multiple of it, the same across and down, up to + /// `srtexture.max_side`: what is drawn from the picture is drawn at its size. A 1024x1024 + /// picture replaces a 256x256 one, and draws it four times as sharp. + multiples, + + /// Whether a mod picture `size` pixels in size can replace one of the original's `own`. + fn allows(sizes: Sizes, size: [2]u32, own: [2]u32) bool { + if (std.mem.eql(u32, &size, &own)) return true; + return switch (sizes) { + .original => false, + .multiples => own[0] != 0 and own[1] != 0 and + size[0] % own[0] == 0 and size[1] % own[1] == 0 and + size[0] / own[0] == size[1] / own[1] and + @max(size[0], size[1]) <= srtexture.max_side, + }; + } +}; + +test Sizes { + try std.testing.expect(Sizes.original.allows(.{ 256, 256 }, .{ 256, 256 })); + try std.testing.expect(!Sizes.original.allows(.{ 512, 512 }, .{ 256, 256 })); + // Twice and four times the size, but not half, not one and a half, and not stretched. + try std.testing.expect(Sizes.multiples.allows(.{ 512, 512 }, .{ 256, 256 })); + try std.testing.expect(Sizes.multiples.allows(.{ 1440, 1440 }, .{ 360, 360 })); + try std.testing.expect(!Sizes.multiples.allows(.{ 128, 128 }, .{ 256, 256 })); + try std.testing.expect(!Sizes.multiples.allows(.{ 384, 384 }, .{ 256, 256 })); + try std.testing.expect(!Sizes.multiples.allows(.{ 512, 256 }, .{ 256, 256 })); + // A picture too large for every GPU. + try std.testing.expect(!Sizes.multiples.allows(.{ 16384, 16384 }, .{ 256, 256 })); } /// Reads and decodes the game's TGA picture `picture_name` from `archive` (`tga.decode`). @@ -198,13 +239,17 @@ test "a mod's picture replaces one of the game's pictures" { try std.testing.expectEqual(4, modded.width()); try std.testing.expectEqual(3, modded.levels.len); try std.testing.expectEqual(std.math.maxInt(u8), modded.levels[0].texels[3]); - // For its pixels, only at the original size. - const colours = try readPixels(gpa, archive, "dome.tga"); + // For its pixels, at the original size, or at a whole multiple of it where that's allowed. + const colours = try readPixels(gpa, archive, "dome.tga", .original); defer colours.deinit(gpa); try std.testing.expectEqualSlices(u8, &.{ 0xFF, 0, 0, 0xFF, 0xFF, 0xFF }, colours.rgb); - const kept = try readPixels(gpa, archive, "back.tga"); + const kept = try readPixels(gpa, archive, "back.tga", .original); defer kept.deinit(gpa); try std.testing.expectEqual([3]u8{ 0, 0, 0xFF }, kept.pixel(0, 0)); + const doubled = try readPixels(gpa, archive, "back.tga", .multiples); + defer doubled.deinit(gpa); + try std.testing.expectEqual(4, doubled.width); + try std.testing.expectEqual([3]u8{ 0x80, 0x80, 0x80 }, doubled.pixel(3, 1)); } test pictureName { diff --git a/src/engine/interface/loadout/loadout.zig b/src/engine/interface/loadout/loadout.zig index 4d8490ba..ad0ffa1d 100644 --- a/src/engine/interface/loadout/loadout.zig +++ b/src/engine/interface/loadout/loadout.zig @@ -183,7 +183,7 @@ pub const title_font_name = "ld_handel.fnt"; test "Newtown stands in for the tooltip's font" { try std.testing.expect(hud.outline.standsIn(title_font_name)); } -const stats_font_name = "handels.fnt"; +pub const stats_font_name = "handels.fnt"; const sounds_name = "ldsmp.fat"; /// The textures the loadout requires (`0x004EAA5C` on): the disc's four quarters, the glow, the @@ -230,7 +230,7 @@ const hum_pitch = -12; /// A panel's texture (`texture_create_transient`), and the pixels the loadout draws it with. const PanelTexture = struct { - pixels: *panels.Image, + pixels: panels.Image, image: srtexture.Image, /// A texture not made yet, as one is while the loadout loads. @@ -545,8 +545,9 @@ pub const Loadout = struct { /// `bpanels`, which the buttons take their art from too (`0x00524214` on). info_textures: std.EnumArray(panels.Face, PanelTexture) = .initFill(.unmade), title_textures: std.EnumArray(panels.Face, PanelTexture) = .initFill(.unmade), - /// `fpanels.tga`'s pixels (`0x00523960`), which the name panels' textures start from. - panels_art: *panels.Image = undefined, + /// `fpanels.tga`'s pixels (`0x00523960`), which the name panels' textures start from, and + /// whose size the panels' textures take (`panels.Image`). + panels_art: panels.Image = undefined, /// Each ship's figures as the loadout works them out as it loads (`loadout_ship_stats`, /// `0x004EC278`). figures: []tables.ShipFigures = &.{}, @@ -726,19 +727,18 @@ pub const Loadout = struct { } loadout.backdrop = try matmanager.readImage(arena, resources.*, backdrop(context)); - loadout.panels_art = try arena.create(panels.Image); - loadout.panels_art.* = @splat(.{ 0, 0, 0, 0 }); - const art = try matmanager.readPixels(arena, resources.*, panels_name); - for (0..@min(art.height, panels.size)) |y| for (0..@min(art.width, panels.size)) |x| { - loadout.panels_art[y * panels.size + x] = art.pixel(x, y) ++ .{hud.Pane.opaque_alpha}; + const art = try matmanager.readPixels(arena, resources.*, panels_name, .multiples); + loadout.panels_art = try .create(arena, @max(1, art.width / panels.size)); + const side = loadout.panels_art.side(); + for (0..@min(art.height, side)) |y| for (0..@min(art.width, side)) |x| { + loadout.panels_art.pixels[y * side + x] = art.pixel(x, y) ++ .{hud.Pane.opaque_alpha}; }; try loadout.openFonts(); for (loadout.panelTextures()) |faces| { for (&faces.values) |*texture| { - texture.pixels = try arena.create(panels.Image); - texture.pixels.* = @splat(.{ 0, 0, 0, 0 }); + texture.pixels = try .create(arena, loadout.panels_art.multiple); const levels = try arena.alloc(srtexture.Level, 1); - levels[0] = .{ .width = panels.size, .height = panels.size, .texels = std.mem.asBytes(texture.pixels) }; + levels[0] = .{ .width = side, .height = side, .texels = std.mem.sliceAsBytes(texture.pixels.pixels) }; texture.image = .{ .levels = levels }; } } @@ -897,10 +897,11 @@ pub const Loadout = struct { gunship.object.shown = false; } - /// Opens the loadout's two fonts (`font_open`), the title's taking VFX's palette through the - /// text remap for the tooltip, in 6-bit levels as a font's palette holds them, and the outline - /// font that stands in for it where the tooltip is written over the screen; the panels' - /// textures keep its glyphs. + /// Opens the loadout's two fonts (`font_open`), and the outline fonts that stand in for them + /// where the tooltip is written over the screen and in panels larger than the original's + /// (`hud.drawTextInto`). Both take VFX's palette through the text remap, in 6-bit levels as a + /// font's palette holds them: the title's for the tooltip, and both to fit their outline + /// fonts by. fn openFonts(loadout: *Loadout) !void { const arena = loadout.arena.allocator(); const resources = loadout.context.rooms.resources; @@ -910,9 +911,11 @@ pub const Loadout = struct { for (loadout.tooltip_palette[level * 3 ..][0..3], colour) |*to, from| to.* = from >> 2; } loadout.title_font = .open(try .parse(try resources.readFile(arena, title_font_name)), &loadout.tooltip_palette); - if (loadout.context.rooms.outlines) |outlines| try loadout.title_font.standIn(outlines, title_font_name); - loadout.stats_font = .open(try .parse(try resources.readFile(arena, stats_font_name)), null); + loadout.stats_font = .open(try .parse(try resources.readFile(arena, stats_font_name)), &loadout.tooltip_palette); loadout.fonts_open = true; + const outlines = loadout.context.rooms.outlines orelse return; + try loadout.title_font.standIn(outlines, title_font_name); + try loadout.stats_font.standIn(outlines, stats_font_name); } /// `loadout_glow_create` (`0x00444720`): the disc's glow, a square of `glow` 10 across, and its @@ -1016,11 +1019,18 @@ pub const Loadout = struct { loadout.drawInfo(.front, .{ .ship = front }); loadout.drawInfo(.back, .{ .ship = back }); const title = &loadout.title_textures; - panels.drawTitle(title.get(.front).pixels, loadout.panels_art, loadout.kit(), loadout.shipRecord(front)); - panels.drawTitle(title.get(.back).pixels, loadout.panels_art, loadout.kit(), loadout.shipRecord(back)); + for ([_]panels.Face{ .front, .back }, [_]u8{ front, back }) |face, ship| { + panels.drawTitle(title.get(face).pixels, loadout.panels_art, loadout.kit(), loadout.shipRecord(ship)) catch |err| textLeftOut(err); + } for (&title.values) |*texture| texture.image.changed = true; } + /// What drawing a panel does where it can't draw its text for want of memory for its outline + /// font's glyphs (`hud.drawTextInto`): the text is left out, which the log says. + fn textLeftOut(err: Allocator.Error) void { + log.warn("a panel's text is left out: {s}", .{@errorName(err)}); + } + /// What the info panel shows on a face: a ship's figures, a missile's, or a ship's guns. const Info = union(enum) { ship: u8, @@ -1032,11 +1042,12 @@ pub const Loadout = struct { /// `missile_info_draw`, `0x004456F0`; `guns_draw`, `0x00445490`). fn drawInfo(loadout: *Loadout, face: panels.Face, info: Info) void { const texture = loadout.info_textures.getPtr(face); - switch (info) { + const drawn = switch (info) { .ship => |ship| panels.drawStats(texture.pixels, loadout.kit(), loadout.shipRecord(ship), loadout.figures[ship]), .missile => |missile| panels.drawMissile(texture.pixels, loadout.kit(), missile, loadout.missile_figures[loadout.missilePlace(missile)]), .guns => |ship| panels.drawGuns(texture.pixels, loadout.kit(), loadout.shipRecord(ship)), - } + }; + drawn catch |err| textLeftOut(err); texture.image.changed = true; } diff --git a/src/engine/interface/loadout/panels.zig b/src/engine/interface/loadout/panels.zig index 7c33e7b9..a49057fd 100644 --- a/src/engine/interface/loadout/panels.zig +++ b/src/engine/interface/loadout/panels.zig @@ -4,6 +4,7 @@ //! (`hud.Pane`), and the scene shows it on one face of a two-sided panel (`Face`). const std = @import("std"); +const Allocator = std.mem.Allocator; const tga = @import("../../../formats/tga.zig"); const hud = @import("../../game/hud.zig"); @@ -11,12 +12,42 @@ const language = @import("../../game/language.zig"); const bars = @import("bars.zig"); const tables = @import("tables.zig"); -/// The panels' textures' size, across and down (`0x00444D83`, `0x00444F40`). +/// The panels' textures' size in the original, across and down (`0x00444D83`, `0x00444F40`). pub const size = 256; -/// A panel's pixels: `size` across and down, four bytes a pixel (red, green, blue and alpha), rows -/// from the top. -pub const Image = [size * size][4]u8; +/// A panel's pixels: `size` times `multiple` across and down, four bytes a pixel (red, green, blue +/// and alpha), rows from the top. +/// +/// **Improvement:** a mod's picture for `fpanels.tga` can be a whole multiple of its size +/// (`matmanager.Sizes.multiples`), and the panels are then drawn as many times as large, with their +/// text from the outline fonts that stand in for theirs (`hud.Pane`). The game's own have a +/// `multiple` of 1. +pub const Image = struct { + pixels: [][4]u8, + /// How many of its pixels across and down stand for each of the original's. + multiple: u32, + + /// A clear image `multiple` times the original's size, in `gpa`. + pub fn create(gpa: Allocator, multiple: u32) Allocator.Error!Image { + const pixels = try gpa.alloc([4]u8, @as(usize, size * multiple) * size * multiple); + @memset(pixels, .{ 0, 0, 0, 0 }); + return .{ .pixels = pixels, .multiple = multiple }; + } + + pub fn deinit(image: Image, gpa: Allocator) void { + gpa.free(image.pixels); + } + + /// Its pixels across and down. + pub fn side(image: Image) u32 { + return size * image.multiple; + } + + /// The pane its text is written through. + fn pane(image: Image) hud.Pane { + return .{ .rgba = image.pixels, .size = @splat(image.side()), .scale = image.multiple }; + } +}; /// Which of a panel's two faces a texture is drawn for. pub const Face = enum { @@ -78,23 +109,23 @@ const InfoPage = struct { pane: hud.Pane, remap: hud.Remap, - fn begin(image: *Image, kit: Kit) InfoPage { - image.* = @splat(.{ 0, 0, 0, 0 }); + fn begin(image: Image, kit: Kit) InfoPage { + @memset(image.pixels, .{ 0, 0, 0, 0 }); return .{ .kit = kit, - .pane = .{ .rgba = image, .size = .{ size, size } }, + .pane = image.pane(), .remap = .{ .table = &tables.text_remap, .palette = kit.palette }, }; } /// `text` from `at`, aligned as `alignment` says (`hud_text`). - fn write(page: InfoPage, at: [2]i32, text: []const u8, alignment: hud.Align) void { - _ = hud.drawTextInto(page.pane, page.kit.info_font, at, text, page.remap, alignment); + fn write(page: InfoPage, at: [2]i32, text: []const u8, alignment: hud.Align) Allocator.Error!void { + _ = try hud.drawTextInto(page.pane, page.kit.info_font, at, text, page.remap, alignment); } /// `text` from `at`, broken into lines at most `width` wide, `line_height` apart, at most /// `max_lines` of them (`hud_text_wrapped`); returns how many it took. - fn wrap(page: InfoPage, at: [2]i32, text: []const u8, width: i32, line_height: i32, max_lines: usize) usize { + fn wrap(page: InfoPage, at: [2]i32, text: []const u8, width: i32, line_height: i32, max_lines: usize) Allocator.Error!usize { return hud.drawWrappedInto(page.pane, page.kit.info_font, at, text, page.remap, .left, width, line_height, max_lines); } }; @@ -110,13 +141,13 @@ const name_at: [2]i32 = .{ 17, 28 }; /// pixels of `fpanels.tga` from the top row down, which the game keeps as `SR_TGA_allocate_raw` /// gives them (`0x00523960`), then `SHIPS AND LOADOUT` and the ship's name in capitals, in the /// title's font and colours (`tables.title_remap`). -pub fn drawTitle(image: *Image, panels: *const Image, kit: Kit, record: tables.Ship) void { - image.* = panels.*; - const pane: hud.Pane = .{ .rgba = image, .size = .{ size, size } }; +pub fn drawTitle(image: Image, panels: Image, kit: Kit, record: tables.Ship) Allocator.Error!void { + @memcpy(image.pixels, panels.pixels); + const pane = image.pane(); const remap: hud.Remap = .{ .table = &tables.title_remap, .palette = kit.palette }; - _ = hud.drawTextInto(pane, kit.title_font, title_at, kit.string(title_string), remap, .left); + _ = try hud.drawTextInto(pane, kit.title_font, title_at, kit.string(title_string), remap, .left); var name = kit.capitals(record.name); - _ = hud.drawTextInto(pane, kit.title_font, name_at, name.slice(), remap, .left); + _ = try hud.drawTextInto(pane, kit.title_font, name_at, name.slice(), remap, .left); } /// The labels before the class and the access, `CLASS : ` and `ACCESS : `. @@ -149,43 +180,43 @@ const specials_separator = ", "; /// access after their labels; its rows (`tables.ship_rows`), each label in capitals and the /// figure of `figures` beside it, as a number and its suffix or as a bar (`drawBar`); and the /// specials it has, in capitals, one after another (`specialsLine`). -pub fn drawStats(image: *Image, kit: Kit, record: tables.Ship, figures: tables.ShipFigures) void { +pub fn drawStats(image: Image, kit: Kit, record: tables.Ship, figures: tables.ShipFigures) Allocator.Error!void { const page: InfoPage = .begin(image, kit); var class: Line = .{}; class.append(kit.string(class_label)); class.append(kit.string(record.class.string())); - page.write(class_at, class.slice(), .left); + try page.write(class_at, class.slice(), .left); var access: Line = .{}; access.append(kit.string(access_label)); access.append(kit.string(record.access.string())); - page.write(access_at, access.slice(), .left); + try page.write(access_at, access.slice(), .left); var y: i32 = first_row_y; for (tables.ship_rows, figures) |row, figure| { - drawRow(page, y, row, figure); + try drawRow(page, y, row, figure); y += row_step; } var specials = specialsLine(kit, record.specials); language.upperCase(specials.slice()); - _ = page.wrap(specials_at, specials.slice(), specials_width, specials_line_height, specials_lines); + _ = try page.wrap(specials_at, specials.slice(), specials_width, specials_line_height, specials_lines); } /// A row of figures at `y` (`loadout_draw_stats`, `0x00445180` to `0x004452F4`; /// `missile_info_draw`, `0x0044593B` to `0x00445ADE`): its label in capitals, and beside it the /// figure, as a number and its suffix or as a bar (`drawBar`), or a dash where there is none. -fn drawRow(page: InfoPage, y: i32, row: tables.Row, figure: ?i32) void { +fn drawRow(page: InfoPage, y: i32, row: tables.Row, figure: ?i32) Allocator.Error!void { const kit = page.kit; var label = kit.capitals(row.label); - page.write(.{ label_x, y }, label.slice(), .left); + try page.write(.{ label_x, y }, label.slice(), .left); const shown = figure orelse return page.write(.{ figure_x, y }, no_figure, .left); switch (row.kind) { .number => { var number: Line = .{}; number.print("{d}", .{shown}); number.append(if (row.suffix) |suffix| kit.string(suffix) else ""); - page.write(.{ figure_x, y }, number.slice(), .left); + try page.write(.{ figure_x, y }, number.slice(), .left); }, .bar => drawBar(page.pane, kit.palette, .{ figure_x, y }, shown), } @@ -216,20 +247,20 @@ const click_at = [2][2]i32{ .{ 128, 195 }, .{ 128, 210 } }; /// capitals, the description wrapped; `CLICK MISSILE` and `TO ATTACH TO SHIP` centred low down; /// and its rows (`tables.missile_rows`), each with its figure of `figures`, or a dash for a figure /// of -1 and for every row of the fuel pod (`0x004459B2`). -pub fn drawMissile(image: *Image, kit: Kit, missile: tables.Missile, figures: tables.MissileFigures) void { +pub fn drawMissile(image: Image, kit: Kit, missile: tables.Missile, figures: tables.MissileFigures) Allocator.Error!void { const page: InfoPage = .begin(image, kit); const record = missile.record(); var name = kit.capitals(record.name); - page.write(missile_name_at, name.slice(), .left); + try page.write(missile_name_at, name.slice(), .left); var description = kit.capitals(record.description); - _ = page.wrap(description_at, description.slice(), description_width, description_line_height, description_lines); - for (click_lines, click_at) |line, at| page.write(at, kit.string(line), .centre); + _ = try page.wrap(description_at, description.slice(), description_width, description_line_height, description_lines); + for (click_lines, click_at) |line, at| try page.write(at, kit.string(line), .centre); var y: i32 = missile_first_row_y; for (tables.missile_rows, figures) |row, figure| { const value: ?i32 = if (missile == .fuel_pod) null else if (row.dash_for_none) bars.MissileBars.shown(figure) else figure; - drawRow(page, y, row, value); + try drawRow(page, y, row, value); y += row_step; } } @@ -253,20 +284,20 @@ const no_drain = ","; /// it, but for a rear turret (`tables.first_rear_turret` on), its description wrapped: its power, /// kind, range, rate and drain run together (`%s%s%s%s%s`, `0x004EAE9C`), in the strings' own /// case. -pub fn drawGuns(image: *Image, kit: Kit, record: tables.Ship) void { +pub fn drawGuns(image: Image, kit: Kit, record: tables.Ship) Allocator.Error!void { const page: InfoPage = .begin(image, kit); var y: i32 = 0; for (record.guns) |mount| { var line: Line = .{}; line.print("{s} X {d}", .{ kit.string(mount.gun), mount.count }); language.upperCase(line.slice()); - page.write(.{ gun_x, y }, line.slice(), .left); + try page.write(.{ gun_x, y }, line.slice(), .left); y += gun_step; const description = tables.gunDescription(mount.gun) orelse continue; var sentence: Line = .{}; for ([_]u16{ description.power, description.kind, description.range, description.rate }) |part| sentence.append(kit.string(part)); sentence.append(if (description.drain) |drain| kit.string(drain) else no_drain); - const lines = page.wrap(.{ gun_x, y }, sentence.slice(), gun_description_width, gun_description_line_height, gun_description_lines); + const lines = try page.wrap(.{ gun_x, y }, sentence.slice(), gun_description_width, gun_description_line_height, gun_description_lines); y += @as(i32, @intCast(lines)) * gun_description_line_height + gun_description_gap; } } @@ -394,22 +425,59 @@ test drawTitle { const strings = testStrings(&buffer); const kit: Kit = .{ .title_font = &opened, .info_font = &opened, .palette = &palette, .strings = &strings }; - const panels = try std.testing.allocator.create(Image); - defer std.testing.allocator.destroy(panels); - panels.* = @splat(.{ 1, 2, 3, 4 }); - const image = try std.testing.allocator.create(Image); - defer std.testing.allocator.destroy(image); - drawTitle(image, panels, kit, tables.ships[0]); + const gpa = std.testing.allocator; + const panels: Image = try .create(gpa, 1); + defer panels.deinit(gpa); + @memset(panels.pixels, .{ 1, 2, 3, 4 }); + const image: Image = try .create(gpa, 1); + defer image.deinit(gpa); + try drawTitle(image, panels, kit, tables.ships[0]); // The title's letters' tops in black, their edges bright, and the panel's own pixels where // they are clear. - try std.testing.expectEqual(colourOf(&palette, 0), image[title_at[1] * size + title_at[0]]); - try std.testing.expectEqual(colourOf(&palette, 0xB8), image[title_at[1] * size + title_at[0] + 1]); - try std.testing.expectEqual([4]u8{ 1, 2, 3, 4 }, image[(title_at[1] + 1) * size + title_at[0]]); + try std.testing.expectEqual(colourOf(&palette, 0), image.pixels[title_at[1] * size + title_at[0]]); + try std.testing.expectEqual(colourOf(&palette, 0xB8), image.pixels[title_at[1] * size + title_at[0] + 1]); + try std.testing.expectEqual([4]u8{ 1, 2, 3, 4 }, image.pixels[(title_at[1] + 1) * size + title_at[0]]); // The name, `PREDATOR`: eight letters. const name_end = name_at[0] + 8 * 2; - try std.testing.expectEqual(colourOf(&palette, 0), image[name_at[1] * size + name_end - 2]); - try std.testing.expectEqual([4]u8{ 1, 2, 3, 4 }, image[name_at[1] * size + name_end]); + try std.testing.expectEqual(colourOf(&palette, 0), image.pixels[name_at[1] * size + name_end - 2]); + try std.testing.expectEqual([4]u8{ 1, 2, 3, 4 }, image.pixels[name_at[1] * size + name_end]); +} + +test "a larger picture draws larger panels" { + const font = try @import("../../../formats/fnt.zig").Font.parse(comptime testFont()); + const opened: hud.Opened = .open(font, null); + const palette = testPalette(); + var buffer: [0x555][]const u8 = undefined; + const strings = testStrings(&buffer); + const kit: Kit = .{ .title_font = &opened, .info_font = &opened, .palette = &palette, .strings = &strings }; + const gpa = std.testing.allocator; + const panels: Image = try .create(gpa, 2); + defer panels.deinit(gpa); + @memset(panels.pixels, .{ 1, 2, 3, 4 }); + const image: Image = try .create(gpa, 2); + defer image.deinit(gpa); + const side = 2 * size; + try std.testing.expectEqual(side, image.side()); + + // Each of the original's pixels is a square of two by two: the title's letters' tops in black, + // their edges bright, over the panel's own pixels. + try drawTitle(image, panels, kit, tables.ships[0]); + const corner = 2 * title_at[1] * side + 2 * title_at[0]; + for ([_]usize{ 0, 1, side, side + 1 }) |at| { + try std.testing.expectEqual(colourOf(&palette, 0), image.pixels[corner + at]); + try std.testing.expectEqual(colourOf(&palette, 0xB8), image.pixels[corner + 2 + at]); + } + try std.testing.expectEqual([4]u8{ 1, 2, 3, 4 }, image.pixels[corner + 2 * side]); + + // So are the bars' segments: the first row's first, filled from its corner to the row above its + // foot. + try drawStats(image, kit, tables.ships[0], .{ 6, 6, 10, 5, 3, 5, 100, 2 }); + const bar = colourOf(&palette, tables.bar_colour); + const segment = 2 * first_row_y * side + 2 * figure_x; + try std.testing.expectEqual(bar, image.pixels[segment]); + try std.testing.expectEqual(bar, image.pixels[segment + (2 * segment_foot - 1) * side + 2 * segment_right + 1]); + try std.testing.expectEqual([4]u8{ 0, 0, 0, 0 }, image.pixels[segment + 2 * segment_foot * side]); } test drawStats { @@ -420,36 +488,37 @@ test drawStats { const strings = testStrings(&buffer); const kit: Kit = .{ .title_font = &opened, .info_font = &opened, .palette = &palette, .strings = &strings }; - const image = try std.testing.allocator.create(Image); - defer std.testing.allocator.destroy(image); - image.* = @splat(.{ 9, 9, 9, 9 }); - drawStats(image, kit, tables.ships[0], .{ 6, 6, 10, 5, 3, 5, 100, 2 }); + const gpa = std.testing.allocator; + const image: Image = try .create(gpa, 1); + defer image.deinit(gpa); + @memset(image.pixels, .{ 9, 9, 9, 9 }); + try drawStats(image, kit, tables.ships[0], .{ 6, 6, 10, 5, 3, 5, 100, 2 }); const top = colourOf(&palette, tables.text_remap[15]); const bar = colourOf(&palette, tables.bar_colour); const clear: [4]u8 = .{ 0, 0, 0, 0 }; // The class, `CLASS : LIGHT`, 13 letters from its corner, on a clear image. - try std.testing.expectEqual(top, image[class_at[1] * size + class_at[0]]); - try std.testing.expectEqual(top, image[class_at[1] * size + class_at[0] + 12 * 2]); - try std.testing.expectEqual(clear, image[class_at[1] * size + class_at[0] + 13 * 2]); + try std.testing.expectEqual(top, image.pixels[class_at[1] * size + class_at[0]]); + try std.testing.expectEqual(top, image.pixels[class_at[1] * size + class_at[0] + 12 * 2]); + try std.testing.expectEqual(clear, image.pixels[class_at[1] * size + class_at[0] + 13 * 2]); // The first row's label, and its bar: six segments filled, the seventh outlined. - try std.testing.expectEqual(top, image[first_row_y * size + label_x]); + try std.testing.expectEqual(top, image.pixels[first_row_y * size + label_x]); const row = first_row_y * size; - try std.testing.expectEqual(bar, image[row + figure_x]); - try std.testing.expectEqual(bar, image[row + 3 * size + figure_x + 5 * segment_step + 2]); - try std.testing.expectEqual(clear, image[row + segment_foot * size + figure_x + 5 * segment_step + 2]); - try std.testing.expectEqual(bar, image[row + figure_x + 6 * segment_step]); - try std.testing.expectEqual(clear, image[row + 3 * size + figure_x + 6 * segment_step + 2]); - try std.testing.expectEqual(bar, image[row + segment_foot * size + figure_x + 6 * segment_step + 2]); - try std.testing.expectEqual(bar, image[row + 3 * size + figure_x + 9 * segment_step + segment_right]); - try std.testing.expectEqual(clear, image[row + figure_x + 9 * segment_step + segment_right + 1]); + try std.testing.expectEqual(bar, image.pixels[row + figure_x]); + try std.testing.expectEqual(bar, image.pixels[row + 3 * size + figure_x + 5 * segment_step + 2]); + try std.testing.expectEqual(clear, image.pixels[row + segment_foot * size + figure_x + 5 * segment_step + 2]); + try std.testing.expectEqual(bar, image.pixels[row + figure_x + 6 * segment_step]); + try std.testing.expectEqual(clear, image.pixels[row + 3 * size + figure_x + 6 * segment_step + 2]); + try std.testing.expectEqual(bar, image.pixels[row + segment_foot * size + figure_x + 6 * segment_step + 2]); + try std.testing.expectEqual(bar, image.pixels[row + 3 * size + figure_x + 9 * segment_step + segment_right]); + try std.testing.expectEqual(clear, image.pixels[row + figure_x + 9 * segment_step + segment_right + 1]); // The afterburner fuel as a number and its seconds, `100 SECS`: eight letters. const fuel = (first_row_y + 6 * row_step) * size + figure_x; - try std.testing.expectEqual(top, image[fuel + 7 * 2]); - try std.testing.expectEqual(clear, image[fuel + 8 * 2]); + try std.testing.expectEqual(top, image.pixels[fuel + 7 * 2]); + try std.testing.expectEqual(clear, image.pixels[fuel + 8 * 2]); // The Predator's one special. - try std.testing.expectEqual(top, image[specials_at[1] * size + specials_at[0] + 9 * 2]); - try std.testing.expectEqual(clear, image[specials_at[1] * size + specials_at[0] + 10 * 2]); + try std.testing.expectEqual(top, image.pixels[specials_at[1] * size + specials_at[0] + 9 * 2]); + try std.testing.expectEqual(clear, image.pixels[specials_at[1] * size + specials_at[0] + 10 * 2]); } test drawMissile { @@ -459,34 +528,35 @@ test drawMissile { var buffer: [0x555][]const u8 = undefined; const strings = testStrings(&buffer); const kit: Kit = .{ .title_font = &opened, .info_font = &opened, .palette = &palette, .strings = &strings }; - const image = try std.testing.allocator.create(Image); - defer std.testing.allocator.destroy(image); + const gpa = std.testing.allocator; + const image: Image = try .create(gpa, 1); + defer image.deinit(gpa); const top = colourOf(&palette, tables.text_remap[15]); const bar = colourOf(&palette, tables.bar_colour); const clear: [4]u8 = .{ 0, 0, 0, 0 }; - drawMissile(image, kit, .havoc, .{ 2, 5, 6, 4 }); + try drawMissile(image, kit, .havoc, .{ 2, 5, 6, 4 }); // Its name, `HAVOK`, five letters from its corner. const name = missile_name_at[1] * size + missile_name_at[0]; - try std.testing.expectEqual(top, image[name + 4 * 2]); - try std.testing.expectEqual(clear, image[name + 5 * 2]); + try std.testing.expectEqual(top, image.pixels[name + 4 * 2]); + try std.testing.expectEqual(clear, image.pixels[name + 5 * 2]); // `CLICK MISSILE`, thirteen letters, centred. const click = click_at[0][1] * size + click_at[0][0]; - try std.testing.expectEqual(top, image[click - 13]); - try std.testing.expectEqual(clear, image[click - 14]); + try std.testing.expectEqual(top, image.pixels[click - 13]); + try std.testing.expectEqual(clear, image.pixels[click - 14]); // The locking time in seconds, `2 SECS`, and the speed's bar, five segments filled. const locking = missile_first_row_y * size + figure_x; - try std.testing.expectEqual(top, image[locking + 5 * 2]); - try std.testing.expectEqual(clear, image[locking + 6 * 2]); + try std.testing.expectEqual(top, image.pixels[locking + 5 * 2]); + try std.testing.expectEqual(clear, image.pixels[locking + 6 * 2]); const speed = (missile_first_row_y + row_step + 3) * size + figure_x; - try std.testing.expectEqual(bar, image[speed + 4 * segment_step + 2]); - try std.testing.expectEqual(clear, image[speed + 5 * segment_step + 2]); + try std.testing.expectEqual(bar, image.pixels[speed + 4 * segment_step + 2]); + try std.testing.expectEqual(clear, image.pixels[speed + 5 * segment_step + 2]); // The fuel pod's rows are dashes, whatever its figures. - drawMissile(image, kit, .fuel_pod, .{ 2, 5, 6, 4 }); - try std.testing.expectEqual(top, image[locking]); - try std.testing.expectEqual(clear, image[locking + 2]); - try std.testing.expectEqual(clear, image[speed + 2]); + try drawMissile(image, kit, .fuel_pod, .{ 2, 5, 6, 4 }); + try std.testing.expectEqual(top, image.pixels[locking]); + try std.testing.expectEqual(clear, image.pixels[locking + 2]); + try std.testing.expectEqual(clear, image.pixels[speed + 2]); } test drawGuns { @@ -496,25 +566,26 @@ test drawGuns { var buffer: [0x555][]const u8 = undefined; const strings = testStrings(&buffer); const kit: Kit = .{ .title_font = &opened, .info_font = &opened, .palette = &palette, .strings = &strings }; - const image = try std.testing.allocator.create(Image); - defer std.testing.allocator.destroy(image); - image.* = @splat(.{ 9, 9, 9, 9 }); + const gpa = std.testing.allocator; + const image: Image = try .create(gpa, 1); + defer image.deinit(gpa); + @memset(image.pixels, .{ 9, 9, 9, 9 }); const top = colourOf(&palette, tables.text_remap[15]); const clear: [4]u8 = .{ 0, 0, 0, 0 }; // The Predator's guns: two lasers, `LASER X 2`, nine letters at the top, on a clear image. - drawGuns(image, kit, tables.ships[0]); - try std.testing.expectEqual(top, image[gun_x + 8 * 2]); - try std.testing.expectEqual(clear, image[gun_x + 9 * 2]); + try drawGuns(image, kit, tables.ships[0]); + try std.testing.expectEqual(top, image.pixels[gun_x + 8 * 2]); + try std.testing.expectEqual(clear, image.pixels[gun_x + 9 * 2]); // Their description, one line of seven letters. const description = gun_step * size + gun_x; - try std.testing.expectEqual(top, image[description + 6 * 2]); - try std.testing.expectEqual(clear, image[description + 7 * 2]); + try std.testing.expectEqual(top, image.pixels[description + 6 * 2]); + try std.testing.expectEqual(clear, image.pixels[description + 7 * 2]); // Then the rear turret, `REAR X 1`, with no description under it. const turret = (gun_step + gun_description_line_height + gun_description_gap) * size + gun_x; - try std.testing.expectEqual(top, image[turret + 7 * 2]); - try std.testing.expectEqual(clear, image[turret + 8 * 2]); - try std.testing.expectEqual(clear, image[turret + gun_step * size]); + try std.testing.expectEqual(top, image.pixels[turret + 7 * 2]); + try std.testing.expectEqual(clear, image.pixels[turret + 8 * 2]); + try std.testing.expectEqual(clear, image.pixels[turret + gun_step * size]); } test specialsLine { diff --git a/src/openreliant/main.zig b/src/openreliant/main.zig index 62f77811..84dc6b93 100644 --- a/src/openreliant/main.zig +++ b/src/openreliant/main.zig @@ -522,12 +522,12 @@ fn run(io: Io, gpa: Allocator, arena: Allocator, options: Options, game_path: [] // apart from the game's for the same reason, and apart from drawing's, so that drawing can run // beside the game frames. var effects_random: engine.random.Random = .{}; - const space = try game.backdrop.Backdrop.create(arena, &textures, try game.matmanager.readPixels(arena, resources, game.backdrop.star_map_name), &rand, context.projection.near, options.sun); + const space = try game.backdrop.Backdrop.create(arena, &textures, try game.matmanager.readPixels(arena, resources, game.backdrop.star_map_name, .original), &rand, context.projection.near, options.sun); // The sun's textures that the backdrop drew again go up to the GPU with the next loading frame, // not in the frame that first shows the sun: the texture table doesn't hold them, so a // mission's loading doesn't send them up (`Play.start`). for (space.redrawnTextures()) |*made| gpu.sendAhead(&made.image); - const sky = try game.nebula.Sky.create(arena, &textures, try game.matmanager.readPixels(arena, resources, game.nebula.dome_image_name)); + const sky = try game.nebula.Sky.create(arena, &textures, try game.matmanager.readPixels(arena, resources, game.nebula.dome_image_name, .original)); try sky.select(&textures, game.nebula.default_nebula, &space.lights); // What the mission's script asks of its space: the nebula it shows, and the effects it turns // on.