Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ www-test/
## TeaVM:
# Not sure yet...

## Cloudflare Wrangler:
.wrangler/

## IntelliJ, Android Studio:
.idea/
*.ipr
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ The UI of JOric has been designed primarily with mobile devices in mind, so give
- e.g. [https://oric.games/?url=https://defence-force.org/files/space1999-en.zip](https://oric.games/?url=https://defence-force.org/files/space1999-en.zip)
- Support for loading games attached to forum posts:
- e.g. [https://oric.games/?url=https://forum.defence-force.org/download/file.php?id=4084](https://oric.games/?url=https://forum.defence-force.org/download/file.php?id=4084)
- Support for specifying the ROM to use for individual game loads via a `?rom=` request parameter:
- e.g. [https://oric.games/?rom=oric1#/basic](https://oric.games/?rom=oric1#/basic)
- e.g. `https://oric.games/?rom=oric1&url=https://example.com/my-oric1-game.tap`
- Valid values are `atmos` (BASIC 1.1, the default) and `oric1` (BASIC 1.0).
- Being a PWA (Progressive Web App), it can be installed locally to your device!
- And it also comes as a standalone Java app, for those who prefer Java.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import emu.joric.MachineType;
import emu.joric.PixelData;
import emu.joric.Program;
import emu.joric.RomConfig;
import emu.joric.config.AppConfigItem;
import emu.joric.cpu.Cpu6502;
import emu.joric.memory.RamType;
Expand Down Expand Up @@ -61,7 +62,9 @@ private void runProgram(AppConfigItem appConfigItem, Program program) {
machine = new Machine(psg, keyboardMatrix, pixelData);

// Load the ROM files.
byte[] basicRom = Gdx.files.internal("roms/basic11b.rom").readBytes();
RomConfig.Option romOpt = RomConfig.resolveRom(
appConfigItem, Gdx.app.getPreferences("joric.preferences"));
byte[] basicRom = Gdx.files.internal("roms/" + romOpt.filename).readBytes();
byte[] microdiscRom = Gdx.files.internal("roms/microdis.rom").readBytes();

machine.init(basicRom, microdiscRom, program,
Expand Down
53 changes: 53 additions & 0 deletions android/src/main/java/emu/joric/android/AndroidLauncher.java
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,59 @@ public void onClick(DialogInterface dialog, int which) {
});
}

@Override
public void promptForOption(final String title, final String message, final String[] options,
final String currentSelection, final TextInputResponseHandler textInputResponseHandler) {
runOnUiThread(new Runnable() {
@Override
public void run() {
int initialIndex = -1;
if (currentSelection != null) {
for (int i = 0; i < options.length; i++) {
if (currentSelection.equals(options[i])) {
initialIndex = i;
break;
}
}
}
final int[] chosen = { initialIndex };

AlertDialog.Builder builder = new AlertDialog.Builder(AndroidLauncher.this);
if (title != null && !title.isEmpty()) {
builder.setTitle(title);
}
if (message != null && !message.isEmpty()) {
builder.setMessage(message);
}
builder.setSingleChoiceItems(options, initialIndex, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
chosen[0] = which;
}
});
builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if (chosen[0] >= 0 && chosen[0] < options.length) {
textInputResponseHandler.inputTextResult(true, options[chosen[0]]);
} else {
textInputResponseHandler.inputTextResult(false, null);
}
dialog.cancel();
}
});
builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
textInputResponseHandler.inputTextResult(false, null);
dialog.cancel();
}
});
builder.create().show();
}
});
}

@Override
public boolean isDialogOpen() {
// Not required for Android, so simply return false regardless of state.
Expand Down
Binary file added assets/roms/basic10.rom
Binary file not shown.
75 changes: 71 additions & 4 deletions core/src/main/java/emu/joric/HomeScreen.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.Table;
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.scenes.scene2d.utils.Drawable;
import com.badlogic.gdx.utils.Align;
Expand Down Expand Up @@ -223,10 +224,15 @@ private void addAppButtonsToStage(Stage stage, PaginationWidget paginationWidget

Button infoButton = buildButton("INFO", null, "png/info.png", 96, 96, null, null);
currentPage.add().expandX();
currentPage.add(infoButton).pad(30, 0, 0, 20).align(Align.right).expandX();
// Right pad is 40 (not 20) so the info button's right edge lines up
// horizontally with the ADD button's right edge. The ADD button sits
// inside a 116-wide WidgetGroup (with the mini settings cog occupying
// the group's bottom-right corner), which pushes ADD's right edge
// 20 px further in from the window edge than the cog.
currentPage.add(infoButton).pad(30, 0, 0, 40).align(Align.right).expandX();
currentPage.row();
currentPage.add().expandX();

if (viewportManager.isLandscape()) {
// Landscape.
container.setBackground(new Image(backgroundLandscape).getDrawable());
Expand All @@ -244,11 +250,46 @@ private void addAppButtonsToStage(Stage stage, PaginationWidget paginationWidget
float titlePadding = ((1080 - titleWidth) / 2);
currentPage.add(title).width(titleWidth).height(197).pad(0, titlePadding, 0, titlePadding).expand();
}

Button addButton = buildButton("ADD", null, "png/open_file.png", 96, 96, null, null);

// Mini settings cog tucked into the bottom-right corner of the ADD
// button. The icon is loaded fresh (bypassing buildButton's texture
// cache) so the 16x16 native PNG upscales cleanly at 2x to 32x32 with
// nearest-neighbour filtering.
//
// Both icons are visually circular inside their square bounds (with
// transparent padding around the visible art), so the squares can
// overlap a little at the corner without the visible circles touching.
// The group is sized so a small visual gap remains between the two
// icons when rendered. Tune by adjusting GROUP_SIZE: larger = bigger
// gap, smaller = icons closer (and eventually overlapping).
Texture miniSettingsTexture = new Texture(Gdx.files.internal("png/settings.png"));
miniSettingsTexture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest);
Image miniSettingsIcon = new Image(miniSettingsTexture);
miniSettingsIcon.setName("SETTINGS");
miniSettingsIcon.addListener(appClickListener);

final int GROUP_SIZE = 116;
final int ADD_SIZE = 96;
final int COG_SIZE = 32;
WidgetGroup addGroup = new WidgetGroup() {
@Override
public float getPrefWidth() { return GROUP_SIZE; }
@Override
public float getPrefHeight() { return GROUP_SIZE; }
};
addGroup.setSize(GROUP_SIZE, GROUP_SIZE);
addButton.setSize(ADD_SIZE, ADD_SIZE);
addButton.setPosition(0, GROUP_SIZE - ADD_SIZE); // top-left (libGDX y grows upward)
addGroup.addActor(addButton);
miniSettingsIcon.setSize(COG_SIZE, COG_SIZE);
miniSettingsIcon.setPosition(GROUP_SIZE - COG_SIZE, 0); // bottom-right
addGroup.addActor(miniSettingsIcon);

currentPage.row();
currentPage.add().expandX();
currentPage.add(addButton).pad(0, 0, 30, 20).align(Align.right).expandX();
currentPage.add(addGroup).pad(0, 0, 30, 20).align(Align.right).expandX();

PagedScrollPane pagedScrollPane = new PagedScrollPane();
pagedScrollPane.setHomeScreen(this);
Expand Down Expand Up @@ -765,6 +806,8 @@ public void clicked(InputEvent event, float x, float y) {
showAboutJOricDialog();
} else if (appName.equals("ADD")) {
importProgram();
} else if (appName.equals("SETTINGS")) {
showSettingsDialog();
}
}
}
Expand Down Expand Up @@ -812,6 +855,30 @@ public void inputTextResult(boolean success, String button) {
});
}

private void showSettingsDialog() {
final RomConfig.Option current = RomConfig.getSelected(joric.getPreferences());
final String[] displayNames = new String[RomConfig.OPTIONS.length];
for (int i = 0; i < RomConfig.OPTIONS.length; i++) {
displayNames[i] = RomConfig.OPTIONS[i].displayName;
}
dialogHandler.promptForOption("Settings", "ROM for local files:", displayNames, current.displayName,
new TextInputResponseHandler() {
@Override
public void inputTextResult(boolean success, String text) {
if (!success || text == null) {
return;
}
// Look up the Option whose displayName matches the chosen string.
for (RomConfig.Option option : RomConfig.OPTIONS) {
if (option.displayName.equals(text)) {
RomConfig.setSelected(joric.getPreferences(), option.id);
return;
}
}
}
});
}

private void exportState() {

}
Expand Down
165 changes: 165 additions & 0 deletions core/src/main/java/emu/joric/RomConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package emu.joric;

import com.badlogic.gdx.Preferences;

import emu.joric.config.AppConfigItem;

/**
* Configuration of available ROM options for JOric.
*
* The user can select which ROM the emulator loads via a settings dialog.
* Selection is persisted in JOric's Preferences under KEY_SELECTED_ROM.
*
* To add a new ROM option:
* 1. Place the .rom file in assets/roms/
* 2. Add a new entry to OPTIONS below
*/
public final class RomConfig {

/** A single ROM choice exposed to the user. */
public static final class Option {
public final String id; // stable identifier stored in preferences
public final String displayName;
public final String filename; // filename in assets/roms/

public Option(String id, String displayName, String filename) {
this.id = id;
this.displayName = displayName;
this.filename = filename;
}
}

/** Available ROM options. The first entry is the default. */
public static final Option[] OPTIONS = {
new Option("atmos", "Oric Atmos (BASIC 1.1)", "basic11b.rom"),
new Option("oric1", "Oric-1 (BASIC 1.0)", "basic10.rom"),
};

/** Preference key under which the selected ROM id is stored. */
public static final String KEY_SELECTED_ROM = "selected_rom";

/**
* ROM id captured from the page's {@code ?rom=} URL parameter. Populated
* by the GWT platform at page load when the URL is launching a specific
* program. Cleared by {@link #takeUrlRomParam()} when consumed, so only
* the launch that originated with the URL uses it — subsequent launches
* fall through to the normal resolution path.
*/
private static String urlRomParam;

private RomConfig() {}

/**
* Returns the currently selected ROM Option, falling back to the default
* (first option) if no preference is set or the stored id is unknown.
*/
public static Option getSelected(Preferences preferences) {
String id = preferences != null ? preferences.getString(KEY_SELECTED_ROM, null) : null;
if (id != null) {
for (Option opt : OPTIONS) {
if (opt.id.equals(id)) return opt;
}
}
return OPTIONS[0];
}

/**
* Save the selected ROM id to preferences. Pass an Option.id (not displayName).
*/
public static void setSelected(Preferences preferences, String id) {
if (preferences == null) return;
preferences.putString(KEY_SELECTED_ROM, id);
preferences.flush();
}

/**
* Stores the ROM id supplied via the page's {@code ?rom=} URL parameter,
* to be applied to the next program launched. Silently ignores ids that
* don't match any known option, so callers can safely pass unvalidated
* input straight from the URL.
*/
public static void setUrlRomParam(String id) {
if (id == null) return;
// Be forgiving about common URL typos — trim whitespace and trailing
// slashes (e.g. "?rom=oric1/#/basic" where the user put the slash
// before the # by analogy with path URLs).
id = id.trim();
while (id.endsWith("/")) {
id = id.substring(0, id.length() - 1);
}
if (id.isEmpty()) return;
for (Option opt : OPTIONS) {
if (opt.id.equals(id)) {
urlRomParam = id;
return;
}
}
// Unknown id — ignore.
}

/**
* Returns the pending URL {@code ?rom=} Option and clears the field, so
* the value is applied at most once. Returns null if no URL ROM parameter
* was captured, or if it has already been taken.
*/
public static Option takeUrlRomParam() {
if (urlRomParam == null) return null;
String id = urlRomParam;
urlRomParam = null;
for (Option opt : OPTIONS) {
if (opt.id.equals(id)) return opt;
}
return null;
}

/**
* Returns the Option whose id matches the given id, or null if no such
* option exists (or id is null/empty).
*/
public static Option findById(String id) {
if (id == null || id.isEmpty()) return null;
for (Option opt : OPTIONS) {
if (opt.id.equals(id)) return opt;
}
return null;
}

/**
* Resolves which ROM to use for launching a given program.
* <p>
* There are three distinct sources, each with a clearly-defined scope:
* <ul>
* <li><b>URL {@code ?rom=} parameter</b> — applies to the program being
* launched from this URL. Highest priority, so sharing a URL gives
* the recipient the same ROM the sender intended regardless of
* their local settings.</li>
* <li><b>{@code AppConfigItem.rom}</b> — the curator's ROM declaration
* for a specific entry in programs.json. Applies to curated
* launches (home screen tiles) and to URL launches that don't
* carry their own {@code ?rom=}.</li>
* <li><b>Persisted preference</b> (Settings dialog) — the user's ROM
* choice for local-file loads (drag-drop and file picker).</li>
* </ul>
* A single launch uses at most one of the above, in that order of
* priority. If none apply, the Atmos default is used.
* <p>
* Local-file launches are distinguished by having a non-empty, non-http
* filePath. Everything else (curated tiles including BASIC, and URL
* launches via {@code ?url=} or a {@code #/slug} route) is treated as
* curator-or-default territory — the persisted preference is not
* consulted.
*/
public static Option resolveRom(AppConfigItem appConfigItem, Preferences preferences) {
Option urlRom = takeUrlRomParam();
if (urlRom != null) return urlRom;

String filePath = appConfigItem != null ? appConfigItem.getFilePath() : null;
boolean localFile = filePath != null && !filePath.isEmpty() && !filePath.startsWith("http");
if (localFile) {
return getSelected(preferences);
}
Option curatorRom = findById(appConfigItem != null ? appConfigItem.getRom() : null);
if (curatorRom != null) return curatorRom;
return OPTIONS[0]; // Atmos default
}
}
Loading