GBC启动动画 88×31 网页按钮
GBC Boot Animation 88×31 Web Button

原始链接: https://zakhary.dev/blog/gbc-web-button

## 复古网页按钮制作:深入解析 受90年代网页美学复兴的启发,作者着手制作一个自定义的88x31像素网页按钮,其中包含Game Boy Color启动动画。由于找不到满意的现成版本,他们使用模拟器和断点,从Game Boy的启动ROM中仔细提取了动画,捕捉了175个单独的帧。 这个过程涉及使用ImageMagick进行大量的图像处理。这包括裁剪动画、缩放以适应按钮尺寸、添加经典的灰色边框,以及关键地,修复原始动画白色背景造成的重影效果。这需要识别并重新映射颜色过渡为灰色,借助AI生成的脚本辅助完成。 作者详细介绍了每个步骤中使用的复杂的ImageMagick命令,突出了该工具的强大和灵活性。最终,他们成功创建了一个复古风格的Game Boy按钮,分享了最终产品和整个过程——希望任天堂不会介意!这个项目证明了作者的奉献精神和对图像处理的深入研究。

## 怀旧早期网页按钮 最近一篇来自Hacker News的帖子,由zakhary.dev发布,展示了经典88x31网页按钮的重现,引发了一波对90年代中期网页美学的怀旧之情。用户们 fondly 回忆了围绕这些按钮构建的“运动”——用于抵制和自豪地展示编码选择(例如,使用记事本而不是Frontpage)。 讨论迅速扩展到回忆论坛签名中的用户条和论坛本身的衰落,这归因于社交媒体的兴起。评论者注意到那个时代出人意料的一致的设计选择,特别是无处不在的像素字体。 几位用户分享了探索这段历史的资源,包括按钮的存档集合,以及指向相关文档甚至互联网档案馆中的旧“操作指南”的链接。对话还涉及GIF格式的技术演变以及保存早期网络内容的挑战。最终,这篇帖子庆祝了互联网历史中一个狭小但备受喜爱的方面,并激励了许多人重温一个更简单、更具“民间艺术”色彩的网络时代。
相关文章

原文

Like many other 90s styles coming back in fashion, I’ve been seeing those retro 88x31 web buttons on more personal websites these days. What a throwback. Naturally, I scoured the internet to find buttons to add to my footer (see below). Since I couldn’t find a Game Boy one that I liked, obviously I had to make my own.

There’s only one problem: I’m not patient (read: talented) enough to make the art myself.

My idea was to use the boot animation from the Game Boy Color placed inside the traditional grey frame. Something like this:

Mock-up of the web button
A rough mock-up of the web button.

Not too complicated, right? So you’d think.

First, we’ve got to find a way to export the animation from boot ROM. The easiest way I could think of to do that was to play it in an emulator and save screenshots of each frame individually. Well, in order to do that we need a way to stop the emulator at each frame. Thankfully, the emulator I used has breakpoints.

So, where should those breakpoints go? Time for a quick crash course:

The animation is programmed into the Game Boy’s boot ROM in GBZ80 assembly. This is proprietary code written by Nintendo that powers up the system and validates the cartridge before handing off execution to the game. Thankfully, some smart people have done the hard work of (1) dumping, (2) disassembling, and (3) labelling the boot ROM for us.

For each Game Boy frame, there’s a period of time where the LCD idles before drawing the next frame. This is called vblank. Taking a look at the disassembly, we can see where vblank occurs:

; =============== S U B R O U T I N E =======================================

; Wait until LCD VBlank Interrupt is flagged.
;
; Input: None.
; Output: None.

Wait_for_next_VBLANK:
    push    hl
    ld  hl, $FF0F
    res 0, [hl]

_wait_vblank_loop:
    bit 0, [hl]       ; wait until hardware sets the vblank flag (bit 0 of FF0F)
    jr  z, _wait_vblank_loop
    pop hl
    ret
; End of function Wait_for_next_VBLANK

Using the debugger, we can see that this function is called from the following code:

sub_0291:
    call    Wait_for_next_VBLANK
    ; -- snip --

As we can see from the generated label sub_0291, this block probably lives at address $0291. Putting a breakpoint here and stepping into the function reveals the address of Wait_for_next_VBLANK to be $0211. After a quick reset and setting the breakpoint, we can step through each frame of the animation.

This next part was a bit tedious: I repeatedly continued the debugger, taking an emulator screenshot at each frame. Once that was done, I had 175 screenshots at 160x144 saved as PNGs on my desktop. Using some quick ✨ magick ✨, I collected these into a GIF with this command:

magick -delay 1.6742706299 -loop 0 *.png(n) animation.gif

Of note is the delay time, ~0.0167s, and the zsh-ism *.png(n) to sort the expanded glob.

Anyways, here’s what it looks like:

Game Boy Color boot animation
Captured animation from the Game Boy Color boot ROM.

Now that we’ve copied Nintendo’s homework made our Game Boy animation, it’s time to reshape it into an artistic web button masterpiece.

Cropping

Next up we’ve got to crop the animation to the desired 88x31. Let’s load up the GIF to see how big the logo is. I used Aseprite for this, but really any application that lets you count pixels in an image will do. Doing this, we see the “Game Boy” text logo is… 127x22 pixels wide. Hmm. That’s too big to fit into an 88x31 button, but I guess that makes sense considering the Game Boy Color’s screen is 160x144 pixels. It’ll have to be scaled down later.

Measuring the logo’s starting location to be (x: 16, y: 48), we can now crop away. Cropping a GIF sounds like it should be a lot of work, but it can be easily accomplished on the CLI with, you guessed it, magick:

magick animation.gif -crop 127x22+16+48 +repage cropped.gif
Animated Game Boy logo
Cropped animation showing the Game Boy logo.

Scaling

The cropped logo needs to be scaled to fit into 88x31. Say it with me folks. magick:

magick cropped.gif -resize 82x scaled.gif

This scales (resizes) the GIF to be 82 pixels wide while maintaining the aspect ratio, the result being 82x14. I chose 82 pixels wide specifically since that’ll allow us to fit for the next stage.

Animated Game Boy logo (downscaled)
Scaled animation showing the (smaller) Game Boy logo.

Framing

Several of the 88x31 buttons I found online have a common frame with a 2-pixel-wide border. It looks like this:

Blank 88x31 template with centre Empty 88x31 frame without centre
Common 88x31 button frame, with and without the centre.

To apply this frame to the scaled animation, we’ll need to do the following:

  1. Centre the scaled animation into a 88x31 space;
  2. Fill in the newly added space with grey;
  3. Add the border frame on top of the animation.

Hopefully you know the drill by now:

magick scaled.gif \
    -gravity center -background "#C0C0C0" -extent 88x31 \
    -coalesce null: frame.png -layers composite \
    framed.gif

Aaaand we’re done! Let’s take a look at the finished product in all its glory.

Framed web button with white surrounding the logo
Something doesn't look quite right here.

Wait, what’s that ugly white square doing there? Oh, right. The animation had a white background. Gotta fix that I guess.

Fixing That

This is actually quite straightforward. Removing the white background is easy, although if we do it with what we currently have there ends up being undesirable artifacts caused by the prior scaling.

magick framed.gif -fill "#C0C0C0" -opaque "#FFFFFF" fixed.gif
Animation with white background removed that has scaling artifacts
Replacing the white background at this stage doesn't really work.

The trick here is to replace the white background before scaling. Here’s also a really great opportunity to fully show off how powerful ImageMagick’s transform pipeline can be. Going back to our uncropped animation, we can apply all previous steps at once like so.

magick animation.gif \
    -crop 127x22+16+48 +repage \         # crop 127x22 logo from animation
    -fill "#C0C0C0" -opaque "#FFFFFF" \  # NEW: replace background with grey
    -resize 82x \                        # scale animation to 82x14
    -gravity center \                    # place logo in centre
    -background "#C0C0C0" \              # use grey for added background
    -extent 88x31 \                      # expand animation to 88x31
    -coalesce null: frame.png \          # apply frame border
    -layers composite \                  # composites each frame
    fixed.gif

It’s amazing that we can do all this in a single command! Let’s admire our finished product.

Game Boy logo animation with ghosting on fadeout
Did anyone else see that ghost?

As we all know, the final stage of any good art project is excising tormented apparitions from our glorious creation!

See that shadow of the logo that appears as it fades away? That’s called ghosting, and is caused by the original animation’s fade going to white instead of the grey we’ve chosen as our new background colour.

Remapping

Fixing this is going to be a little more tricky than it was for the compression artifacts above. In order to fix this, we’ll need remap the transition colours so that the logo’s blue-to-white transition instead fades to grey.

To do this, we’ll first need a way to identify all those transition colours. Extracting the frames of the animation will allow us to analyze their colours in a histogram:

# Extract original animation's frames
mkdir frames
magick animation.gif frames/%03d.png

# Analyze each frame's colours
for frame in frames/*(n); do
    echo "frame: $frame"
    magick "$frame" -format %c histogram:info:
done

Running this script will produce a ton of output. Let’s take a look at some samples near the end.

-- snip --
frame: frames/160.png
           209: (232,232,232) #E8E8E8 grey91
          1560: (232,238,255) #E8EEFF srgb(232,238,255)
         21271: (255,255,255) #FFFFFF white
frame: frames/161.png
           209: (243,243,243) #F3F3F3 srgb(243,243,243)
          1560: (243,246,255) #F3F6FF srgb(243,246,255)
         21271: (255,255,255) #FFFFFF white
frame: frames/162.png
           209: (247,247,247) #F7F7F7 grey97
          1560: (247,249,255) #F7F9FF srgb(247,249,255)
         21271: (255,255,255) #FFFFFF white
frame: frames/163.png
            29: (250,251,255) #FAFBFF srgb(250,251,255)
         23011: (255,255,255) #FFFFFF white
frame: frames/164.png
         23040: (255,255,255) #FFFFFF gray(255)
frame: frames/165.png
         23040: (255,255,255) #FFFFFF gray(255)
frame: frames/166.png
         23040: (255,255,255) #FFFFFF gray(255)
-- snip --

It’s not immediately obvious what we’re looking for here, but we can see that by frame 164 there’s only one colour. That corresponds to the whiteout at the end of the animation. Looking a few frames before, we consistently see 209 frames helpfully labelled as grey, and 1560 frames in some other colour. Conspicuously, that other colour only varies in red and green, but remains fully blue.

Game Boy Color boot animation
Let's take another look at that captured animation.

Matching that up with the animation, those 1560 pixels must be the fading logo. With a little Unix wizardry we can extract the colour hex codes from this output to obtain an exact list of the blue-to-white transition colours!

Fade colours (blue to white)
#006BFF
#066CFF
#0C6DFF
#146FFF
#1C71FF
#2474FF
#2D77FF
#387CFF
#4281FF
#4C86FF
#588DFF
#6494FF
#719CFF
#7DA3FF
#89ABFF
#95B3FF
#A1BBFF
#ACC3FF
#B6CAFF
#C0D1FF
#CAD8FF
#D2DEFF
#DAE4FF
#E1E9FF
#E8EEFF
#F3F6FF
#F7F9FF
#FAFBFF
#FFFFFF

From this list, the logo fade transitions from [#006BFF, #FFFFFF). Since we’re updating the background colour to #C0C0C0, we’ll need a way to modify the transition colours to instead fade to that shade of grey. Functionally, for each colour we (1) compute how far along the transition we are, then (2) use this value to re-compute an equivalent transition colour in the desired fade range.

Here’s where I’ll admit my shame: by this point I was getting lazy and didn’t feel like using my brain to write my own colour interpolation. So I turned to the AI overlords to do this work for me.

Interpolation script (AI slop)
def hex_to_rgb(hex):
    """Convert hex string (#RRGGBB) to RGB tuple."""
    return tuple(int(hex.lstrip('#')[i:i+2], 16) for i in (0, 2, 4))

def rgb_to_hex(rgb):
    """Convert RGB tuple to hex string (#RRGGBB)."""
    return "#{:02X}{:02X}{:02X}".format(*rgb)

def remap_color(color, start_old, end_old, start_new, end_new):
    """Remap a color from old range to new range."""
    r_old, g_old, b_old = start_old
    r_end_old, g_end_old, b_end_old = end_old
    r_new_start, g_new_start, b_new_start = start_new
    r_new_end, g_new_end, b_new_end = end_new
    r, g, b = color

    # Compute relative position t in old range
    t = (
        (r - r_old) / (r_end_old - r_old) if r_end_old != r_old else 0
    )  # using red as representative; you could average channels instead

    # Map to new range
    r_mapped = round(r_new_start + t * (r_new_end - r_new_start))
    g_mapped = round(g_new_start + t * (g_new_end - g_new_start))
    b_mapped = round(b_new_start + t * (b_new_end - b_new_start))

    # Clamp values between 0-255
    r_mapped = min(max(r_mapped, 0), 255)
    g_mapped = min(max(g_mapped, 0), 255)
    b_mapped = min(max(b_mapped, 0), 255)

    return (r_mapped, g_mapped, b_mapped)

# Define old and new ranges
start_old = hex_to_rgb("#006BFF")
end_old   = hex_to_rgb("#FFFFFF")

start_new = hex_to_rgb("#006BFF")
end_new   = hex_to_rgb("#C0C0C0")

# Process file
with open("color.txt", "r") as f:
    colors = [line.strip() for line in f if line.strip()]

fixed_colors = []
for hex_color in colors:
    rgb = hex_to_rgb(hex_color)
    new_rgb = remap_color(rgb, start_old, end_old, start_new, end_new)
    fixed_colors.append(rgb_to_hex(new_rgb))

# Write output
with open("remap.txt", "w") as f:
    f.write("\n".join(fixed_colors))

print("Finished! Fixed colors saved to remap.txt")

Looks great, I’m sure it works fine.

Fade colours (blue to grey)
#006BFF
#056DFE
#096FFC
#0F72FA
#1574F8
#1B77F6
#227AF4
#2A7EF1
#3281EF
#3984EC
#4288E9
#4B8CE6
#5591E3
#5E95E0
#6799DD
#709DDA
#79A1D7
#82A4D5
#89A8D2
#91ABD0
#98AECD
#9EB1CB
#A4B4C9
#A9B6C7
#AFB8C6
#B7BCC3
#BABDC2
#BCBEC1
#C0C0C0

Huh. This looks surprisingly correct. Adding in #FFFFFF to the original list and running again, we see that it does indeed get transformed to #C0C0C0. As an additional sanity check, here are generated images of the palettes we intend to swap.

Palette from blue to white Palette from blue to grey
Comparison of original and replacement colour palettes.

All that’s left is to perform the colour substitution. Admittedly, I was having some trouble doing this using ImageMagick’s -clut, so I arrived at a much less elegant solution: use a series of -fill/-opaque to manually replace each colour. Adding the following to the bottom of the Python script, we can at least automate writing all that out.

# Generate ImageMagick command
cmd = ""
for old, new in zip(colors, fixed_colors):
    cmd += f' -fill "{new}" -opaque "{old}" \\\n'

print(f"Generated ImageMagick replacement:\n{cmd}")
Generated replacement options
-fill "#006BFF" -opaque "#006BFF" \
-fill "#056DFE" -opaque "#066CFF" \
-fill "#096FFC" -opaque "#0C6DFF" \
-fill "#0F72FA" -opaque "#146FFF" \
-fill "#1574F8" -opaque "#1C71FF" \
-fill "#1B77F6" -opaque "#2474FF" \
-fill "#227AF4" -opaque "#2D77FF" \
-fill "#2A7EF1" -opaque "#387CFF" \
-fill "#3281EF" -opaque "#4281FF" \
-fill "#3984EC" -opaque "#4C86FF" \
-fill "#4288E9" -opaque "#588DFF" \
-fill "#4B8CE6" -opaque "#6494FF" \
-fill "#5591E3" -opaque "#719CFF" \
-fill "#5E95E0" -opaque "#7DA3FF" \
-fill "#6799DD" -opaque "#89ABFF" \
-fill "#709DDA" -opaque "#95B3FF" \
-fill "#79A1D7" -opaque "#A1BBFF" \
-fill "#82A4D5" -opaque "#ACC3FF" \
-fill "#89A8D2" -opaque "#B6CAFF" \
-fill "#91ABD0" -opaque "#C0D1FF" \
-fill "#98AECD" -opaque "#CAD8FF" \
-fill "#9EB1CB" -opaque "#D2DEFF" \
-fill "#A4B4C9" -opaque "#DAE4FF" \
-fill "#A9B6C7" -opaque "#E1E9FF" \
-fill "#AFB8C6" -opaque "#E8EEFF" \
-fill "#B7BCC3" -opaque "#F3F6FF" \
-fill "#BABDC2" -opaque "#F7F9FF" \
-fill "#BCBEC1" -opaque "#FAFBFF" \
-fill "#C0C0C0" -opaque "#FFFFFF" \

Putting it all together, we obtain a monstrosity that looks like this:

magick animation.gif \
    -crop 127x22+16+48 +repage \         # crop 127x22 logo from animation
    \ # remap transition colours from white to grey
    -fill "#006BFF" -opaque "#006BFF" \
    -fill "#056DFE" -opaque "#066CFF" \
    -fill "#096FFC" -opaque "#0C6DFF" \
    -fill "#0F72FA" -opaque "#146FFF" \
    -fill "#1574F8" -opaque "#1C71FF" \
    -fill "#1B77F6" -opaque "#2474FF" \
    -fill "#227AF4" -opaque "#2D77FF" \
    -fill "#2A7EF1" -opaque "#387CFF" \
    -fill "#3281EF" -opaque "#4281FF" \
    -fill "#3984EC" -opaque "#4C86FF" \
    -fill "#4288E9" -opaque "#588DFF" \
    -fill "#4B8CE6" -opaque "#6494FF" \
    -fill "#5591E3" -opaque "#719CFF" \
    -fill "#5E95E0" -opaque "#7DA3FF" \
    -fill "#6799DD" -opaque "#89ABFF" \
    -fill "#709DDA" -opaque "#95B3FF" \
    -fill "#79A1D7" -opaque "#A1BBFF" \
    -fill "#82A4D5" -opaque "#ACC3FF" \
    -fill "#89A8D2" -opaque "#B6CAFF" \
    -fill "#91ABD0" -opaque "#C0D1FF" \
    -fill "#98AECD" -opaque "#CAD8FF" \
    -fill "#9EB1CB" -opaque "#D2DEFF" \
    -fill "#A4B4C9" -opaque "#DAE4FF" \
    -fill "#A9B6C7" -opaque "#E1E9FF" \
    -fill "#AFB8C6" -opaque "#E8EEFF" \
    -fill "#B7BCC3" -opaque "#F3F6FF" \
    -fill "#BABDC2" -opaque "#F7F9FF" \
    -fill "#BCBEC1" -opaque "#FAFBFF" \
    -fill "#C0C0C0" -opaque "#FFFFFF" \  # replace background with grey
    -resize 82x \                        # scale animation to 82x14
    -gravity center \                    # place logo in centre
    -background "#C0C0C0" \              # use grey for added background
    -extent 88x31 \                      # expand animation to 88x31
    -coalesce null: frame.png \          # apply frame border
    -layers composite \                  # composites each frame
    button.gif

As someone who doesn’t have an artistic bone in my body (doctor’s diagnosis), I think it turned out pretty great! I learned a lot about ImageMagick throughout this adventure, and I hope you did too. Please feel free to use this button however you wish. Attribution is not at all necessary, but is welcome and appreciated regardless.

Game Boy Color boot animation web button
Here it is in all its glory!

Well, I guess all that’s left is this final plea: Nintendo, please don’t sue me.

联系我们 contact @ memedata.com