Add screen mirroring, watch input, splash screen, and architecture docs
Mirrors the C64 text screen to the Pebble watch (with game-specific umlaut handling), adds a SELECT-triggered key wheel for sending navigation input back to the emulator, shows a splash screen on watch launch, and documents the resulting end-to-end architecture.
This commit is contained in:
@@ -24,10 +24,17 @@
|
||||
},
|
||||
"messageKeys": [
|
||||
"TIME",
|
||||
"COMMAND"
|
||||
"COMMAND",
|
||||
"SCREEN"
|
||||
],
|
||||
"resources": {
|
||||
"media": []
|
||||
"media": [
|
||||
{
|
||||
"type": "bitmap",
|
||||
"name": "IMAGE_SPLASH",
|
||||
"file": "splash.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 405 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -1,15 +1,39 @@
|
||||
#include <pebble.h>
|
||||
|
||||
enum {
|
||||
KEY_TIME = 0, // Inbound: current time string from companion app
|
||||
KEY_COMMAND = 1 // Outbound: button name sent to companion app
|
||||
KEY_TIME = 0, // Inbound: current time string from companion app (unused — see KEY_SCREEN)
|
||||
KEY_COMMAND = 1, // Outbound: button name sent to companion app
|
||||
KEY_SCREEN = 2 // Inbound: C64 text screen (40x25, ASCII, rows separated by '\n')
|
||||
};
|
||||
|
||||
static Window *s_main_window;
|
||||
static TextLayer *s_time_layer;
|
||||
static TextLayer *s_hint_layer;
|
||||
#define SCREEN_FONT FONT_KEY_GOTHIC_24
|
||||
|
||||
static char s_time_buffer[32] = "Waiting...";
|
||||
// Curated navigation-only key set for the SELECT wheel. Movement in this
|
||||
// game is mostly done with number keys, plus RETURN/SPACE to confirm.
|
||||
static const char *const k_wheel_items[] = {
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "RETURN", "SPACE"
|
||||
};
|
||||
#define WHEEL_ITEM_COUNT ((int)(sizeof(k_wheel_items) / sizeof(k_wheel_items[0])))
|
||||
|
||||
static Window *s_main_window;
|
||||
static ScrollLayer *s_scroll_layer;
|
||||
static TextLayer *s_screen_layer;
|
||||
|
||||
static Window *s_wheel_window;
|
||||
static TextLayer *s_wheel_label_layer;
|
||||
static TextLayer *s_wheel_hint_layer;
|
||||
static int s_wheel_index = 0;
|
||||
|
||||
#define SPLASH_DURATION_MS 1800
|
||||
|
||||
static Window *s_splash_window;
|
||||
static BitmapLayer *s_splash_bitmap_layer;
|
||||
static GBitmap *s_splash_bitmap;
|
||||
|
||||
// Worst case 40*25 cells at 2 UTF-8 bytes each (umlaut overrides) + 25
|
||||
// row-separator newlines + NUL, matching the companion app's getScreenText()
|
||||
// output (see vice_jni.c).
|
||||
static char s_screen_buffer[2080] = "Waiting for C64 screen...";
|
||||
|
||||
static void send_key_to_phone(const char *key_str) {
|
||||
DictionaryIterator *iter;
|
||||
@@ -19,69 +43,183 @@ static void send_key_to_phone(const char *key_str) {
|
||||
app_message_outbox_send();
|
||||
}
|
||||
|
||||
// Resize the text layer and scroll layer's content size to fit s_screen_buffer
|
||||
// at the current font/width. Must run whenever the buffer's text changes —
|
||||
// the C64 screen content (and therefore wrapped height) varies frame to frame.
|
||||
static void update_screen_layout(void) {
|
||||
GRect bounds = layer_get_bounds(scroll_layer_get_layer(s_scroll_layer));
|
||||
GFont font = fonts_get_system_font(SCREEN_FONT);
|
||||
GRect measure_box = GRect(0, 0, bounds.size.w, 4000);
|
||||
GSize content_size = graphics_text_layout_get_content_size(
|
||||
s_screen_buffer, font, measure_box, GTextOverflowModeWordWrap, GTextAlignmentLeft);
|
||||
// Floor content height at the viewport height so short text doesn't shrink
|
||||
// the scrollable area below what's visible.
|
||||
int16_t height = content_size.h > bounds.size.h ? content_size.h : bounds.size.h;
|
||||
|
||||
layer_set_frame(text_layer_get_layer(s_screen_layer), GRect(0, 0, bounds.size.w, height));
|
||||
scroll_layer_set_content_size(s_scroll_layer, GSize(bounds.size.w, height));
|
||||
}
|
||||
|
||||
static void inbox_received_callback(DictionaryIterator *iterator, void *context) {
|
||||
Tuple *time_tuple = dict_find(iterator, KEY_TIME);
|
||||
if (time_tuple && time_tuple->type == TUPLE_CSTRING) {
|
||||
snprintf(s_time_buffer, sizeof(s_time_buffer), "%s", time_tuple->value->cstring);
|
||||
text_layer_set_text(s_time_layer, s_time_buffer);
|
||||
layer_mark_dirty(text_layer_get_layer(s_time_layer));
|
||||
Tuple *screen_tuple = dict_find(iterator, KEY_SCREEN);
|
||||
if (screen_tuple && screen_tuple->type == TUPLE_CSTRING) {
|
||||
snprintf(s_screen_buffer, sizeof(s_screen_buffer), "%s", screen_tuple->value->cstring);
|
||||
text_layer_set_text(s_screen_layer, s_screen_buffer);
|
||||
update_screen_layout();
|
||||
layer_mark_dirty(text_layer_get_layer(s_screen_layer));
|
||||
}
|
||||
}
|
||||
|
||||
static void up_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
send_key_to_phone("UP");
|
||||
static void update_wheel_label(void) {
|
||||
text_layer_set_text(s_wheel_label_layer, k_wheel_items[s_wheel_index]);
|
||||
}
|
||||
|
||||
static void down_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
send_key_to_phone("DOWN");
|
||||
static void wheel_up_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
s_wheel_index = (s_wheel_index - 1 + WHEEL_ITEM_COUNT) % WHEEL_ITEM_COUNT;
|
||||
update_wheel_label();
|
||||
}
|
||||
|
||||
static void wheel_down_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
s_wheel_index = (s_wheel_index + 1) % WHEEL_ITEM_COUNT;
|
||||
update_wheel_label();
|
||||
}
|
||||
|
||||
// Sends the highlighted item and pops back to the screen mirror. The BACK
|
||||
// button is left unsubscribed so its default behavior (pop the window) acts
|
||||
// as "cancel" for free.
|
||||
static void wheel_select_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
send_key_to_phone(k_wheel_items[s_wheel_index]);
|
||||
window_stack_pop(true);
|
||||
}
|
||||
|
||||
static void wheel_click_config_provider(void *context) {
|
||||
window_single_click_subscribe(BUTTON_ID_UP, wheel_up_click_handler);
|
||||
window_single_click_subscribe(BUTTON_ID_DOWN, wheel_down_click_handler);
|
||||
window_single_click_subscribe(BUTTON_ID_SELECT, wheel_select_click_handler);
|
||||
}
|
||||
|
||||
static void wheel_window_load(Window *window) {
|
||||
Layer *window_layer = window_get_root_layer(window);
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
|
||||
s_wheel_label_layer = text_layer_create(GRect(0, bounds.size.h / 2 - 24, bounds.size.w, 48));
|
||||
text_layer_set_font(s_wheel_label_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK));
|
||||
text_layer_set_text_alignment(s_wheel_label_layer, GTextAlignmentCenter);
|
||||
update_wheel_label();
|
||||
layer_add_child(window_layer, text_layer_get_layer(s_wheel_label_layer));
|
||||
|
||||
s_wheel_hint_layer = text_layer_create(GRect(0, bounds.size.h - 30, bounds.size.w, 30));
|
||||
text_layer_set_font(s_wheel_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14));
|
||||
text_layer_set_text_alignment(s_wheel_hint_layer, GTextAlignmentCenter);
|
||||
text_layer_set_text(s_wheel_hint_layer, "UP/DOWN spin - SELECT send");
|
||||
layer_add_child(window_layer, text_layer_get_layer(s_wheel_hint_layer));
|
||||
}
|
||||
|
||||
static void wheel_window_unload(Window *window) {
|
||||
text_layer_destroy(s_wheel_label_layer);
|
||||
text_layer_destroy(s_wheel_hint_layer);
|
||||
}
|
||||
|
||||
static void select_click_handler(ClickRecognizerRef recognizer, void *context) {
|
||||
send_key_to_phone("SELECT");
|
||||
window_stack_push(s_wheel_window, true);
|
||||
}
|
||||
|
||||
static void click_config_provider(void *context) {
|
||||
window_single_click_subscribe(BUTTON_ID_UP, up_click_handler);
|
||||
window_single_click_subscribe(BUTTON_ID_DOWN, down_click_handler);
|
||||
// Passed to scroll_layer_set_callbacks(): the ScrollLayer's own click config
|
||||
// provider calls this after wiring UP/DOWN to scroll, so we only need to add
|
||||
// SELECT here — UP/DOWN stay dedicated to panning the text view.
|
||||
static void scroll_click_config_provider(void *context) {
|
||||
window_single_click_subscribe(BUTTON_ID_SELECT, select_click_handler);
|
||||
}
|
||||
|
||||
static void splash_window_load(Window *window) {
|
||||
Layer *window_layer = window_get_root_layer(window);
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
|
||||
s_splash_bitmap = gbitmap_create_with_resource(RESOURCE_ID_IMAGE_SPLASH);
|
||||
s_splash_bitmap_layer = bitmap_layer_create(bounds);
|
||||
bitmap_layer_set_bitmap(s_splash_bitmap_layer, s_splash_bitmap);
|
||||
// Source image is roughly square and smaller than every target screen, so
|
||||
// center it without stretching rather than filling/distorting the frame.
|
||||
bitmap_layer_set_alignment(s_splash_bitmap_layer, GAlignCenter);
|
||||
bitmap_layer_set_background_color(s_splash_bitmap_layer, GColorBlack);
|
||||
layer_add_child(window_layer, bitmap_layer_get_layer(s_splash_bitmap_layer));
|
||||
}
|
||||
|
||||
static void splash_window_unload(Window *window) {
|
||||
bitmap_layer_destroy(s_splash_bitmap_layer);
|
||||
gbitmap_destroy(s_splash_bitmap);
|
||||
}
|
||||
|
||||
// main_window is pushed underneath splash_window at startup (see init()), so
|
||||
// popping splash here just reveals it — the stack is never empty, which
|
||||
// matters because removing the last window on the stack kills the app.
|
||||
static void splash_timeout_handler(void *data) {
|
||||
window_stack_remove(s_splash_window, true);
|
||||
}
|
||||
|
||||
static void main_window_load(Window *window) {
|
||||
Layer *window_layer = window_get_root_layer(window);
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
|
||||
s_time_layer = text_layer_create(GRect(0, 55, bounds.size.w, 50));
|
||||
text_layer_set_text(s_time_layer, s_time_buffer);
|
||||
text_layer_set_font(s_time_layer, fonts_get_system_font(FONT_KEY_BITHAM_30_BLACK));
|
||||
text_layer_set_text_alignment(s_time_layer, GTextAlignmentCenter);
|
||||
layer_add_child(window_layer, text_layer_get_layer(s_time_layer));
|
||||
s_scroll_layer = scroll_layer_create(bounds);
|
||||
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
|
||||
scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks) {
|
||||
.click_config_provider = scroll_click_config_provider
|
||||
});
|
||||
|
||||
s_hint_layer = text_layer_create(GRect(0, 130, bounds.size.w, 30));
|
||||
text_layer_set_text(s_hint_layer, "UP / DN / SEL");
|
||||
text_layer_set_font(s_hint_layer, fonts_get_system_font(FONT_KEY_GOTHIC_14));
|
||||
text_layer_set_text_alignment(s_hint_layer, GTextAlignmentCenter);
|
||||
layer_add_child(window_layer, text_layer_get_layer(s_hint_layer));
|
||||
// The 40-column C64 grid is not preserved — the companion app's text
|
||||
// already has '\n' per row, but at this font size lines reflow
|
||||
// (word-wrap) rather than line up like the original screen.
|
||||
s_screen_layer = text_layer_create(GRect(0, 0, bounds.size.w, bounds.size.h));
|
||||
text_layer_set_text(s_screen_layer, s_screen_buffer);
|
||||
text_layer_set_font(s_screen_layer, fonts_get_system_font(SCREEN_FONT));
|
||||
text_layer_set_text_alignment(s_screen_layer, GTextAlignmentLeft);
|
||||
scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(s_screen_layer));
|
||||
|
||||
update_screen_layout();
|
||||
layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer));
|
||||
}
|
||||
|
||||
static void main_window_unload(Window *window) {
|
||||
text_layer_destroy(s_time_layer);
|
||||
text_layer_destroy(s_hint_layer);
|
||||
text_layer_destroy(s_screen_layer);
|
||||
scroll_layer_destroy(s_scroll_layer);
|
||||
}
|
||||
|
||||
static void init(void) {
|
||||
s_main_window = window_create();
|
||||
window_set_click_config_provider(s_main_window, click_config_provider);
|
||||
window_set_window_handlers(s_main_window, (WindowHandlers) {
|
||||
.load = main_window_load,
|
||||
.unload = main_window_unload
|
||||
});
|
||||
window_stack_push(s_main_window, true);
|
||||
|
||||
s_wheel_window = window_create();
|
||||
window_set_click_config_provider(s_wheel_window, wheel_click_config_provider);
|
||||
window_set_window_handlers(s_wheel_window, (WindowHandlers) {
|
||||
.load = wheel_window_load,
|
||||
.unload = wheel_window_unload
|
||||
});
|
||||
|
||||
s_splash_window = window_create();
|
||||
window_set_window_handlers(s_splash_window, (WindowHandlers) {
|
||||
.load = splash_window_load,
|
||||
.unload = splash_window_unload
|
||||
});
|
||||
// Pushed on top of the already-present main_window, not in place of an
|
||||
// empty stack — see the note on splash_timeout_handler below.
|
||||
window_stack_push(s_splash_window, true);
|
||||
app_timer_register(SPLASH_DURATION_MS, splash_timeout_handler, NULL);
|
||||
|
||||
app_message_register_inbox_received(inbox_received_callback);
|
||||
app_message_open(128, 64);
|
||||
// Inbox must hold the full 40x25 screen text (up to ~2080 bytes with UTF-8
|
||||
// umlaut overrides) plus dictionary overhead; outbox only ever carries a
|
||||
// short button-name string.
|
||||
app_message_open(2200, 64);
|
||||
}
|
||||
|
||||
static void deinit(void) {
|
||||
window_destroy(s_splash_window);
|
||||
window_destroy(s_wheel_window);
|
||||
window_destroy(s_main_window);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ var SERVER = 'http://127.0.0.1:8888';
|
||||
|
||||
var KEY_TIME = 0;
|
||||
var KEY_COMMAND = 1;
|
||||
var KEY_SCREEN = 2;
|
||||
|
||||
function httpGet(url) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
@@ -12,15 +13,15 @@ function httpGet(url) {
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function sendTimeToWatch() {
|
||||
function sendScreenToWatch() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', SERVER + '/time', true);
|
||||
xhr.open('GET', SERVER + '/screen', true);
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
var msg = {};
|
||||
msg[KEY_TIME] = data.time;
|
||||
msg[KEY_SCREEN] = data.text;
|
||||
Pebble.sendAppMessage(
|
||||
msg,
|
||||
function() {},
|
||||
@@ -32,13 +33,13 @@ function sendTimeToWatch() {
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
console.log('[SuM] GET /time failed - is the companion app running?');
|
||||
console.log('[SuM] GET /screen failed - is the companion app running?');
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
Pebble.addEventListener('ready', function() {
|
||||
setInterval(sendTimeToWatch, 1000);
|
||||
setInterval(sendScreenToWatch, 1000);
|
||||
});
|
||||
|
||||
Pebble.addEventListener('appmessage', function(e) {
|
||||
|
||||
Reference in New Issue
Block a user