diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..67df35c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# https://editorconfig.org +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{java,scala,groovy,kt,kts}] +indent_size = 4 + +[*.gradle] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6c84be0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.bat text=auto eol=crlf diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..867d3e3 --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..f08ef67 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,118 @@ +apply plugin: 'com.android.application' + + +android { + compileSdk 32 + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs('src/main/java') + aidl.srcDirs('src/main/java') + renderscript.srcDirs('src/main/java') + res.srcDirs('res') + assets.srcDirs('../assets') + jniLibs.srcDirs('libs') + } + } + packagingOptions { + resources.with { + excludes += ['META-INF/robovm/ios/robovm.xml', + 'META-INF/DEPENDENCIES.txt', 'META-INF/DEPENDENCIES', 'META-INF/dependencies.txt', '**/*.gwt.xml'] + pickFirsts += ['META-INF/LICENSE.txt', 'META-INF/LICENSE', 'META-INF/license.txt', 'META-INF/LGPL2.1', + 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/notice.txt'] + } + } + defaultConfig { + applicationId 'com.agifans.agile' + minSdkVersion 19 + targetSdkVersion 32 + versionCode 1 + versionName "1.0" + multiDexEnabled true + } + namespace "com.agifans.agile" + compileOptions { + sourceCompatibility "11" + targetCompatibility "11" + coreLibraryDesugaringEnabled true + } + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + // needed for AAPT2, may be needed for other tools + google() +} + +configurations { natives } + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' + implementation "com.badlogicgames.gdx:gdx-backend-android:$gdxVersion" + implementation project(':core') + + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-arm64-v8a" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-armeabi-v7a" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86" + natives "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-x86_64" + +} + +// Called every time gradle gets executed, takes the native dependencies of +// the natives configuration, and extracts them to the proper libs/ folders +// so they get packed with the APK. +tasks.register('copyAndroidNatives') { + doFirst { + file("libs/armeabi-v7a/").mkdirs() + file("libs/arm64-v8a/").mkdirs() + file("libs/x86_64/").mkdirs() + file("libs/x86/").mkdirs() + + configurations.named("natives").orNull.copy().files.each { jar -> + def outputDir = null + if(jar.name.endsWith("natives-armeabi-v7a.jar")) outputDir = file("libs/armeabi-v7a") + if(jar.name.endsWith("natives-arm64-v8a.jar")) outputDir = file("libs/arm64-v8a") + if(jar.name.endsWith("natives-x86_64.jar")) outputDir = file("libs/x86_64") + if(jar.name.endsWith("natives-x86.jar")) outputDir = file("libs/x86") + if(outputDir != null) { + copy { + from zipTree(jar) + into outputDir + include "*.so" + } + } + } + } +} +tasks.matching { it.name.contains("merge") && it.name.contains("JniLibFolders") }.configureEach { packageTask -> + packageTask.dependsOn 'copyAndroidNatives' +} + +tasks.register('run', Exec) { + def path + def localProperties = project.file("../local.properties") + if (localProperties.exists()) { + Properties properties = new Properties() + localProperties.withInputStream { instr -> + properties.load(instr) + } + def sdkDir = properties.getProperty('sdk.dir') + if (sdkDir) { + path = sdkDir + } else { + path = "$System.env.ANDROID_SDK_ROOT" + } + } else { + path = "$System.env.ANDROID_SDK_ROOT" + } + + def adb = path + "/platform-tools/adb" + commandLine "$adb", 'shell', 'am', 'start', '-n', 'com.agifans.agile/com.agifans.agile.android.AndroidLauncher' +} + +eclipse.project.name = appName + "-android" diff --git a/android/ic_launcher-web.png b/android/ic_launcher-web.png new file mode 100644 index 0000000..0f59902 Binary files /dev/null and b/android/ic_launcher-web.png differ diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro new file mode 100644 index 0000000..9c00b1d --- /dev/null +++ b/android/proguard-rules.pro @@ -0,0 +1,60 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +-verbose + +-dontwarn android.support.** +-dontwarn com.badlogic.gdx.backends.android.AndroidFragmentApplication +-dontwarn com.badlogic.gdx.utils.GdxBuild +-dontwarn com.badlogic.gdx.physics.box2d.utils.Box2DBuild +-dontwarn com.badlogic.gdx.jnigen.BuildTarget* +-dontwarn com.badlogic.gdx.graphics.g2d.freetype.FreetypeBuild + +# If you're encountering ProGuard issues and use gdx-controllers, THIS MIGHT BE WHY!!! + +# Uncomment the following line if you use the gdx-controllers official extension. +#-keep class com.badlogic.gdx.controllers.android.AndroidControllers + +-keepclassmembers class com.badlogic.gdx.backends.android.AndroidInput* { + (com.badlogic.gdx.Application, android.content.Context, java.lang.Object, com.badlogic.gdx.backends.android.AndroidApplicationConfiguration); +} + +-keepclassmembers class com.badlogic.gdx.physics.box2d.World { + boolean contactFilter(long, long); + void beginContact(long); + void endContact(long); + void preSolve(long, long); + void postSolve(long, long); + boolean reportFixture(long); + float reportRayFixture(long, float, float, float, float, float); +} + +# You will need the next three lines if you use scene2d for UI or gameplay +# If you don't use scene2d at all, you can remove or comment out the next line +-keep public class com.badlogic.gdx.scenes.scene2d.** { *; } +# You will need the next two lines if you use BitmapFont or any scene2d.ui text +-keep public class com.badlogic.gdx.graphics.g2d.BitmapFont { *; } +# You will probably need this line in most cases +-keep public class com.badlogic.gdx.graphics.Color { *; } + +# These two lines are used with mapping files; see https://developer.android.com/build/shrink-code#retracing +-keepattributes LineNumberTable,SourceFile +-renamesourcefileattribute SourceFile diff --git a/android/project.properties b/android/project.properties new file mode 100644 index 0000000..aab9a82 --- /dev/null +++ b/android/project.properties @@ -0,0 +1,14 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-rules.pro + +# Project target. +target=android-19 diff --git a/android/res/drawable-hdpi/ic_launcher.png b/android/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..9fca30c Binary files /dev/null and b/android/res/drawable-hdpi/ic_launcher.png differ diff --git a/android/res/drawable-mdpi/ic_launcher.png b/android/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..d0befa9 Binary files /dev/null and b/android/res/drawable-mdpi/ic_launcher.png differ diff --git a/android/res/drawable-xhdpi/ic_launcher.png b/android/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..ba83684 Binary files /dev/null and b/android/res/drawable-xhdpi/ic_launcher.png differ diff --git a/android/res/drawable-xxhdpi/ic_launcher.png b/android/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4225649 Binary files /dev/null and b/android/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml new file mode 100644 index 0000000..9a1c4bf --- /dev/null +++ b/android/res/values/strings.xml @@ -0,0 +1,4 @@ + + + agile + diff --git a/android/res/values/styles.xml b/android/res/values/styles.xml new file mode 100644 index 0000000..ce3571e --- /dev/null +++ b/android/res/values/styles.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/java/com/agifans/agile/android/AndroidLauncher.java b/android/src/main/java/com/agifans/agile/android/AndroidLauncher.java new file mode 100644 index 0000000..a70844f --- /dev/null +++ b/android/src/main/java/com/agifans/agile/android/AndroidLauncher.java @@ -0,0 +1,18 @@ +package com.agifans.agile.android; + +import android.os.Bundle; + +import com.badlogic.gdx.backends.android.AndroidApplication; +import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration; +import com.agifans.agile.Agile; + +/** Launches the Android application. */ +public class AndroidLauncher extends AndroidApplication { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + AndroidApplicationConfiguration configuration = new AndroidApplicationConfiguration(); + configuration.useImmersiveMode = true; // Recommended, but not required. + initialize(new Agile(), configuration); + } +} \ No newline at end of file diff --git a/assets/libgdx.png b/assets/libgdx.png new file mode 100644 index 0000000..6c7bfca Binary files /dev/null and b/assets/libgdx.png differ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..051911a --- /dev/null +++ b/build.gradle @@ -0,0 +1,44 @@ +buildscript { + repositories { + mavenCentral() + maven { url 'https://s01.oss.sonatype.org' } + mavenLocal() + google() + maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } + maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } + } + dependencies { + classpath "com.android.tools.build:gradle:8.1.2" + classpath "org.docstr:gwt-gradle-plugin:$gwtPluginVersion" + + } +} + +allprojects { + apply plugin: 'eclipse' + apply plugin: 'idea' +} + +configure(subprojects - project(':android')) { + apply plugin: 'java-library' + sourceCompatibility = 11 + compileJava { + options.incremental = true + } +} + +subprojects { + version = '1.0.0' + ext.appName = 'agile' + repositories { + mavenCentral() + maven { url 'https://s01.oss.sonatype.org' } + // You may want to remove the following line if you have errors downloading dependencies. + mavenLocal() + maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } + maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } + maven { url 'https://jitpack.io' } + } +} + +eclipse.project.name = 'agile' + '-parent' diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..6d84ca8 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,6 @@ +[compileJava, compileTestJava]*.options*.encoding = 'UTF-8' +eclipse.project.name = appName + '-core' + +dependencies { + api "com.badlogicgames.gdx:gdx:$gdxVersion" +} diff --git a/core/src/main/java/com/agifans/agile/Agile.gwt.xml b/core/src/main/java/com/agifans/agile/Agile.gwt.xml new file mode 100644 index 0000000..f98a40a --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Agile.gwt.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/src/main/java/com/agifans/agile/Agile.java b/core/src/main/java/com/agifans/agile/Agile.java new file mode 100644 index 0000000..46ba493 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Agile.java @@ -0,0 +1,82 @@ +package com.agifans.agile; + +import com.badlogic.gdx.ApplicationAdapter; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.graphics.GL20; +import com.badlogic.gdx.graphics.g2d.SpriteBatch; + +/** {@link com.badlogic.gdx.ApplicationListener} implementation shared by all platforms. */ +public class Agile extends ApplicationAdapter { + + private SpriteBatch batch; + private GameScreen screen; + private AgileRunner agileRunner; + private WavePlayer wavePlayer; + + /** + * Constructor for Agile. + * + * @param agileRunner + * @param wavePlayer + */ + public Agile(AgileRunner agileRunner, WavePlayer wavePlayer) { + this.agileRunner = agileRunner; + this.wavePlayer = wavePlayer; + } + + @Override + public void create() { + batch = new SpriteBatch(); + screen = new GameScreen(); + startGame(selectGame()); + } + + /** + * Starts the AGI game contained in the given game folder. + * + * @param gameFolder The folder from which we'll get all of the game data. + */ + private void startGame(String gameFolder) { + // Register the key event handlers for keyUp, keyDown, and keyTyped. + UserInput userInput = new UserInput(); + Gdx.input.setInputProcessor(userInput); + + agileRunner.init(gameFolder, userInput, wavePlayer, screen.getPixels()); + + // Start up the AgileRunner to run the interpreter in the background. + agileRunner.start(); + } + + /** + * Selects am AGI game folder to run. + * + * @return The folder containing the AGI game's resources. + */ + private String selectGame() { + // TODO: Implement selection logic. This is a placeholder for now. + // TODO: Space Quest 1 VIEW on title screen is mis-placed. + // TODO: MH2 and GR both complain that logic is null. + // TODO: Game clock should stop when in menus or window showing, as should animations. + return "C:\\dev\\agi\\winagi\\kq1"; + } + + @Override + public void render() { + // Update screen. + screen.render(); + + // Render. + Gdx.gl.glClearColor(0.15f, 0.15f, 0.2f, 1f); + Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); + batch.begin(); + batch.draw(screen.getDrawScreen(), 0, 0, 960, 600); + batch.end(); + } + + @Override + public void dispose() { + agileRunner.stop(); + batch.dispose(); + screen.dispose(); + } +} diff --git a/core/src/main/java/com/agifans/agile/AgileRunner.java b/core/src/main/java/com/agifans/agile/AgileRunner.java new file mode 100644 index 0000000..5e525cd --- /dev/null +++ b/core/src/main/java/com/agifans/agile/AgileRunner.java @@ -0,0 +1,156 @@ +package com.agifans.agile; + +import java.io.File; +import java.util.List; + +import com.agifans.agile.agilib.Game; +import com.agifans.agile.agilib.Logic; +import com.agifans.agile.agilib.Logic.Action; +import com.agifans.agile.agilib.Logic.OperandType; + +import com.badlogic.gdx.Gdx; + +/** + * Performs the actual loading and then running of the AGI game. This is an abstract + * class since the code needs to be run in a background thread/worker, which is something + * that is best handled by the platform specific code. Most of the code is in this class + * though, but the launching of the background thread/worker, and its main timing loop, + * is implemented in the sub-classes. + */ +public abstract class AgileRunner { + + protected Interpreter interpreter; + + private String gameFolder; + private WavePlayer wavePlayer; + private UserInput userInput; + private short[] pixels; + + public void init(String gameFolder, UserInput userInput, WavePlayer wavePlayer, short[] pixels) { + this.gameFolder = gameFolder; + this.userInput = userInput; + this.wavePlayer = wavePlayer; + this.pixels = pixels; + } + + /** + * Attempts to load an AGI game from the game folder. + */ + protected void loadGame() { + Game game = null; + + // Use a dummy TextGraphics instance to render the "Loading" text in grand AGI fashion. + TextGraphics textGraphics = new TextGraphics(pixels, null, null); + try { + // TODO: Change to libgdx files?? + File wordsFile = new File(gameFolder + "\\WORDS.TOK"); + if (wordsFile.exists()) { + textGraphics.drawString(pixels, "Loading... Please wait", 72, 88, 15, 0); + } + game = new Game(gameFolder); + } + finally { + textGraphics.clearLines(0, 24, 0); + } + + // Game detection logic and update windows title. + Detection gameDetection = new Detection(game); + Gdx.graphics.setTitle(String.format("AGILE v0.0.0.0 | %s", gameDetection.gameName)); + + // Patch game option. + patchGame(game, gameDetection.gameId, gameDetection.gameName); + + // Create the Interpreter to run this Game. + this.interpreter = new Interpreter(game, userInput, wavePlayer, pixels); + } + + /** + * Patches the given games's Logic scripts, so that the starting question is skipped. + * + * @param game Game to patch the Logics for. + * @param gameId The detected game ID. + * @param gameName The detected game name. + * + * @return The patched Game. + */ + private Game patchGame(Game game, String gameId, String gameName) { + for (Logic logic : game.logics) { + if (logic != null) { + List actions = logic.actions; + + switch (gameId) { + + case "goldrush": + // Gold Rush version 3.0 doesn't have copy protection + if (gameName.contains("3.0")) { + break; + } + if (logic.index == 129) { + // Changes the new.room(125) to be new.room(73) instead, thus skipping the questions. + Action action = actions.get(27); + if ((action.operation.opcode == 18) && (action.operands.get(0).asInt() == 125)) { + action.operands.set(0, logic.new Operand(OperandType.NUM, 73)); + } + } + break; + + case "mh1": + if (logic.index == 159) { + // Modifies LOGIC.159 to jump to the code that is run when a successful answer is entered. + if ((actions.get(134).operation.opcode == 18) && (actions.get(134).operands.get(0).asInt() == 153)) { + actions.set(0, logic.new GotoAction(List.of(logic.new Operand(OperandType.ADDRESS, actions.get(132).address)))); + actions.get(0).logic = logic; + } + } + break; + + case "kq4": + if (logic.index == 0) { + // Changes the new.room(140) to be new.room(96) instead, thus skipping the questions. + Action action = actions.get(55); + if ((action.operation.opcode == 18) && (action.operands.get(0).asInt() == 140)) { + action.operands.set(0, logic.new Operand(OperandType.NUM, 96)); + } + } + break; + + case "lsl1": + if (logic.index == 6) { + // Modifies LOGIC.6 to jump to the code that is run when all of the trivia questions has been answered correctly. + Action action = actions.get(0); + // Verify that the action is the if-condition to check if the user can enter the game. + if (action.operation.opcode == 255 && action.operands.size() == 2) { + actions.set(0, logic.new GotoAction(List.of(logic.new Operand(OperandType.ADDRESS, actions.get(1).address)))); + actions.get(0).logic = logic; + + // Skips the 'Thank you. And now, slip into your leisure suit and prepare to enter the + // "Land of the Lounge Lizards" with "Leisure "Suit Larry!"' message + int printIndex = 9; + Action printAction = actions.get(printIndex); + + // Verify it's the print function + if (printAction.operation.opcode == 101) { + // Go to next command in the logic, which is the new.room command + actions.set(printIndex, logic.new GotoAction(List.of(logic.new Operand(OperandType.ADDRESS, actions.get(printIndex + 1).address)))); + actions.get(printIndex).logic = logic; + } + } + } + break; + + default: + break; + } + } + } + + return game; + } + + public abstract void start(); + + public abstract void stop(); + + public abstract boolean isRunning(); + +} diff --git a/core/src/main/java/com/agifans/agile/AnimatedObject.java b/core/src/main/java/com/agifans/agile/AnimatedObject.java new file mode 100644 index 0000000..eea7da2 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/AnimatedObject.java @@ -0,0 +1,1644 @@ +package com.agifans.agile; + +import com.agifans.agile.agilib.Picture; +import com.agifans.agile.agilib.View; +import com.agifans.agile.agilib.View.Cel; +import com.agifans.agile.agilib.View.Loop; + +/** + * The AnimatedObject class is one of the core classes in the AGI interpreter. An instance of + * this class holds the state of an animated object on the screen. Many of the action commands + * change the state within an instance of AnimatedObject, and the interpreter makes use of + * the instances of this class stored within the animated object table to perform an animation + * cycle. + */ +public class AnimatedObject implements Comparable { + + /** + * Number of animate cycles between moves of the AnimatedObject. Set by step.time action command. + */ + public int stepTime; + + /** + * Count down from StepTime for determining when the AnimatedObject will move. Initially set + * by step.time and it then counts down from there on each animate cycle, resetting back to + * the StepTime value when it hits zero. + */ + public int stepTimeCount; + + /** + * The index of this AnimatedObject in the animated object table. Set to -1 for add.to.pic objects. + */ + public byte objectNumber; + + /** + * Current X position of this AnimatedObject. + */ + public short x; + + /** + * Current Y position of this AnimatedObject. + */ + public short y; + + /** + * The current view number for this AnimatedObject. + */ + public int currentView; + + /** + * The View currently being used by this AnimatedObject. + */ + public View view() { return state.views[currentView]; } + + /** + * The current loop number within the View. + */ + public int currentLoop; + + /** + * The number of loops in the View. + */ + public int numberOfLoops() { return view().loops.size(); } + + /** + * The Loop that is currently cycling for this AnimatedObject. + */ + public Loop loop() { return (Loop)view().loops.get(currentLoop); } + + /** + * The current cell number within the loop. + */ + public int currentCel; + + /** + * The number of cels in the current loop. + */ + public int numberOfCels() { return loop().cels.size(); } + + /** + * The Cel currently being displayed. + */ + public Cel cel() { return (Cel)loop().cels.get(currentCel); } + + /** + * The previous Cel that was displayed. + */ + public Cel previousCel; + + /** + * The background save area for this AnimatedObject. + */ + public SaveArea saveArea; + + /** + * Previous X position. + */ + public short prevX; + + /** + * Previous Y position. + */ + public short prevY; + + /** + * X dimension of the current cel. + */ + public short xSize() { return (short)cel().getWidth(); } + + /** + * Y dimesion of the current cel. + */ + public short ySize() { return (short)cel().getHeight(); } + + /** + * Distance that this AnimatedObject will move on each move. + */ + public int stepSize; + + /** + * The number of animate cycles between changing to the next cel in the current + * loop. Set by the cycle.time action command. + */ + public int cycleTime; + + /** + * Count down from CycleTime for determining when the AnimatedObject will cycle to the next + * cel in the loop. Initially set by cycle.time and it then counts down from there on each + * animate cycle, resetting back to the CycleTime value when it hits zero. + */ + public int cycleTimeCount; + + /** + * The AnimatedObject's direction. + */ + public byte direction; + + /** + * The AnimatedObject's motion type. + */ + public MotionType motionType; + + /** + * The AnimatedObject's cycling type. + */ + public CycleType cycleType; + + /** + * The priority band value for this AnimatedObject. + */ + public byte priority; + + /** + * The control colour of the box around the base of add.to.pic objects. Not application + * to normal AnimatedObjects. + */ + public byte controlBoxColour; + + /** + * true if AnimatedObject is drawn on the screen; otherwise false; + */ + public boolean drawn; + + /** + * true if the AnimatedObject should ignore blocks; otherwise false. Ignoring blocks + * means that it can pass black priority one lines and also script blocks. Set to true + * by the ignore.blocks action command. Set to false by the observe.blocks action + * command. + */ + public boolean ignoreBlocks; + + /** + * true if the AnimatedObject has fixed priority; otherwise false. Set to true by the + * set.priority action command. Set to false by the release.priority action command. + */ + public boolean fixedPriority; + + /** + * true if the AnimatedObject should ignore the horizon; otherwise false. Set to true + * by the ignore.horizon action command. Set to false by the observe.horizon action + * command. + */ + public boolean ignoreHorizon; + + /** + * true if the AnimatedObject should be updated; otherwise false. + */ + public boolean update; + + /** + * true if the AnimatedObject should be cycled; otherwise false. + */ + public boolean cycle; + + /** + * true if the AnimatedObject can move; otherwise false. + */ + public boolean animated; + + /** + * true if the AnimatedObject is blocked; otherwise false. + */ + public boolean blocked; + + /** + * true if the AnimatedObject must stay entirely on water; otherwise false. + */ + public boolean stayOnWater; + + /** + * true if the AnimatedObject must not be entirely on water; otherwise false. + */ + public boolean stayOnLand; + + /** + * true if the AnimatedObject is ignoring collisions with other AnimatedObjects; otherwise false. + */ + public boolean ignoreObjects; + + /** + * true if the AnimatedObject is being repositioned in this cycle; otherwise false. + */ + public boolean repositioned; + + /** + * true if the AnimatedObject should not have the cel advanced in this loop; otherwise false. + */ + public boolean noAdvance; + + /** + * true if the AnimatedObject should not have the loop fixed; otherwise false. Having + * the loop fixed means that it will not adjust according to the direction. Set to + * true by the fix.loop action command. Set to false by the release.loop action command. + */ + public boolean fixedLoop; + + /** + * true if the AnimatedObject did not move in the last animation cycle; otherwise false. + */ + public boolean stopped; + + /** + * Miscellaneous motion parameter 1. Used by Wander, MoveTo, and Follow. + */ + public short motionParam1; + + /** + * Miscellaneous motion parameter 2. + */ + public short motionParam2; + + /** + * Miscellaneous motion parameter 3. + */ + public short motionParam3; + + /** + * Miscellaneous motion parameter 4. + */ + public short motionParam4; + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * Constructor for AnimatedObject. + * + * @param state + * @param objectNum + */ + public AnimatedObject(GameState state, int objectNum) { + this.state = state; + this.objectNumber = (byte)objectNum; + this.saveArea = new SaveArea(); + reset(true); + } + + /** + * Resets the AnimatedObject back to its initial state. + */ + public void reset() { + reset(false); + } + + /** + * Resets the AnimatedObject back to its initial state. + * + * @param fullReset true if it should be a full reset; otherwise false. + */ + public void reset(boolean fullReset) { + animated = false; + drawn = false; + update = true; + + previousCel = null; + saveArea.visBackPixels = null; + saveArea.priBackPixels = null; + + stepSize = 1; + cycleTime = 1; + cycleTimeCount = 1; + stepTime = 1; + stepTimeCount = 1; + + // A full reset is to go back to the initial state, whereas a normal reset is + // simply for changing rooms. + if (fullReset) { + this.blocked = false; + this.controlBoxColour = 0; + this.currentCel = 0; + this.currentLoop = 0; + this.currentView = 0; + this.cycle = false; + this.cycleType = CycleType.NORMAL; + this.direction = 0; + this.fixedLoop = false; + this.fixedPriority = false; + this.ignoreBlocks = false; + this.ignoreHorizon = false; + this.ignoreObjects = false; + this.motionParam1 = 0; + this.motionParam2 = 0; + this.motionParam3 = 0; + this.motionParam4 = 0; + this.motionType = MotionType.NORMAL; + this.noAdvance = false; + this.prevX = this.x = 0; + this.prevY = this.y = 0; + this.priority = 0; + this.repositioned = false; + this.stayOnLand = false; + this.stayOnWater = false; + this.stopped = false; + } + } + + /** + * Updates the AnimatedObject's Direction based on its current MotionType. + */ + public void updateDirection() { + if (animated && update && drawn && (stepTimeCount == 1)) { + switch (motionType) { + case WANDER: + wander(); + break; + + case FOLLOW: + follow(); + break; + + case MOVE_TO: + moveTo(); + break; + + case NORMAL: + // Nothing to do. + break; + } + + // If no blocks are in effect, clear the 'blocked' flag. Otherwise, + // if object must observe blocks, check for blocking. + if (!state.blocking) { + blocked = false; + } + else if (!ignoreBlocks && (direction != 0)) { + checkBlock(); + } + } + } + + /** + * Starts the Wander motion for this AnimatedObject. + */ + public void startWander() { + if (this == state.ego) { + state.userControl = false; + } + this.motionType = MotionType.WANDER; + this.update = true; + } + + /** + * If the AnimatedObject has stopped, but the motion type is Wander, then this + * method picks a random direction and distance. + * + * Note: motionParam1 is used to track the distance. + */ + private void wander() { + // Wander uses general purpose motion parameter 1 for the distance. + if ((motionParam1-- == 0) || stopped) { + direction = (byte)state.random.nextInt(9); + + // If the AnimatedObject is ego, then set the EGODIR var. + if (objectNumber == 0) { + state.vars[Defines.EGODIR] = direction; + } + + motionParam1 = (short)((state.random.nextInt((Defines.MAXDIST - Defines.MINDIST)) + Defines.MINDIST) & 0xFF); + } + } + + /** + * New Direction matrix to support the MoveDirection method. + */ + private static final byte[][] newdir = { {8, 1, 2}, {7, 0, 3}, {6, 5, 4} }; + + /** + * Return the direction from (oldx, oldy) to (newx, newy). If the object is within + * 'delta' of the position in both directions, return 0 + * + * @param oldx + * @param oldy + * @param newx + * @param newy + * @param delta + * + * @return + */ + private byte moveDirection(short oldx, short oldy, short newx, short newy, short delta) { + return (newdir[directionIndex(newy - oldy, delta)][directionIndex(newx - oldx, delta)]); + } + + /** + * Return 0, 1, or 2 depending on whether the difference between coords, d, + * indicates that the coordinate should decrease, stay the same, or increase. + * The return value is used as one of the indeces into 'newdir' above. + * + * @param d + * @param delta + * + * @return 0, 1, or 2, as described in the summary above. + */ + private byte directionIndex(int d, short delta) { + byte index = 0; + + if (d <= -delta) { + index = 0; + } + else if (d >= delta) { + index = 2; + } + else { + index = 1; + } + + return index; + } + + /** + * Move this AnimatedObject towards ego. + * + * motionParam1 (endDist): Distance from ego which is considered to be completion of the motion. + * motionParam2 (endFlag): Flag to set on completion of the motion + * motionParam3 (randDist): Distance to move in current direction (for random search) + */ + private void follow() { + int maxDist = 0; + + // Get coordinates of center of object's & ego's bases. + short ecx = (short)(state.ego.x + (state.ego.xSize() / 2)); + short ocx = (short)(this.x + (this.xSize() / 2)); + + // Get direction from object's center to ego's center. + byte dir = moveDirection(ocx, this.y, ecx, state.ego.y, motionParam1); + + // If the direction is zero, the object and ego have collided, so signal completion. + if (dir == 0) { + this.direction = 0; + this.motionType = MotionType.NORMAL; + this.state.flags[this.motionParam2] = true; + return; + } + + // If the object has not moved since last time, assume it is blocked and + // move in a random direction for a random distance no greater than the + // distance between the object and ego + + // NOTE: randDist = -1 indicates that this is initialization, and thus + // we don't care about the previous position + if (this.motionParam3 == -1) { + this.motionParam3 = 0; + } + else if (this.stopped) { + // Make sure that the object goes in some direction. + direction = (byte)(state.random.nextInt(8) + 1); + + // Average the x and y distances to the object for movement limit. + maxDist = (Math.abs(ocx - ecx) + Math.abs(this.y - state.ego.y)) / 2 + 1; + + // Make sure that the distance is at least the object stepsize. + if (maxDist <= this.stepSize) { + this.motionParam3 = (short)this.stepSize; + } + else { + this.motionParam3 = (short)(state.random.nextInt((maxDist - this.stepSize)) + this.stepSize); + } + + return; + } + + // If 'randDist' is non-zero, keep moving the object in the current direction. + if (this.motionParam3 != 0) { + if ((this.motionParam3 -= this.stepSize) < 0) { + // Down with the random movement. + this.motionParam3 = 0; + } + return; + } + + // Otherwise, just move the object towards ego. Whew... + this.direction = dir; + } + + /** + * Starts a Follow ego motion for this AnimatedObject. + * + * @param dist Distance from ego which is considered to be completion of the motion. + * @param completionFlag The number of the flag to set when the motion is completed. + */ + public void startFollowEgo(int dist, int completionFlag) { + this.motionType = MotionType.FOLLOW; + + // Distance from ego which is considered to be completion of the motion is the larger of + // the object's StepSize and the dist parameter. + this.motionParam1 = (short)(dist > this.stepSize ? dist : this.stepSize); + this.motionParam2 = (short)completionFlag; + this.motionParam3 = -1; // 'follow' routine expects this. + state.flags[completionFlag] = false; // Flag to set at completion. + this.update = true; + } + + /** + * Move this AnimatedObject toward the target (xt, yt) position, as defined below: + * + * motionParam1 (xt): Target X coordinate. + * motionParam2 (yt): Target Y coordinate. + * motionParam3 (oldStep): Old stepsize for this AnimatedObject. + * motionParam4 (endFlag): Flag to set when this AnimatedObject reaches the target position. + */ + public void moveTo() { + // Get the direction to move. + this.direction = moveDirection(this.x, this.y, this.motionParam1, this.motionParam2, (short)this.stepSize); + + // If this AnimatedObject is ego, set var[EGODIR] + if (this.objectNumber == 0) { + this.state.vars[Defines.EGODIR] = this.direction; + } + + // If 0, signal completion. + if (this.direction == 0) { + endMoveObj(); + } + } + + /** + * Starts the MoveTo motion for this AnimatedObject. + * + * @param x The x position to move to. + * @param y The y position to move to. + * @param stepSize The step size to use for the motion. If 0, then the current StepSize value for this AnimatedObject is used. + * @param completionFlag The flag number to set when the motion has completed. + */ + public void startMoveObj(int x, int y, int stepSize, int completionFlag) { + this.motionType = MotionType.MOVE_TO; + this.motionParam1 = (short)x; + this.motionParam2 = (short)y; + this.motionParam3 = (short)this.stepSize; + if (stepSize != 0) { + this.stepSize = stepSize; + } + this.motionParam4 = (short)completionFlag; + state.flags[completionFlag] = false; + this.update = true; + if (this == state.ego) { + state.userControl = false; + } + this.moveTo(); + } + + /** + * Ends the MoveTo motion for this AnimatedObject. + */ + private void endMoveObj() { + // Restore old step size. + this.stepSize = this.motionParam3; + + // Set flag indicating completion. + this.state.flags[this.motionParam4] = true; + + // Set it back to normal motion. + this.motionType = MotionType.NORMAL; + + // If this AnimatedObject is ego, then give back user control. + if (this.objectNumber == 0) { + state.userControl = true; + state.vars[Defines.EGODIR] = 0; + } + } + + /** + * A block is in effect and the object must observe blocks. Check to see + * if the object can move in its current direction. + */ + private void checkBlock() { + boolean objInBlock; + short ox, oy; + + // Get obj coord into temp vars and determine if the object is + // currently within the block. + ox = this.x; + oy = this.y; + + objInBlock = inBlock(ox, oy); + + // Get object coordinate after moving. + switch (this.direction) { + case 1: + oy -= this.stepSize; + break; + + case 2: + ox += this.stepSize; + oy -= this.stepSize; + break; + + case 3: + ox += this.stepSize; + break; + + case 4: + ox += this.stepSize; + oy += this.stepSize; + break; + + case 5: + oy += this.stepSize; + break; + + case 6: + ox -= this.stepSize; + oy += this.stepSize; + break; + + case 7: + ox -= this.stepSize; + break; + + case 8: + ox -= this.stepSize; + oy -= this.stepSize; + break; + } + + // If moving the object will not change its 'in block' status, let it move. + if (objInBlock == inBlock(ox, oy)) { + this.blocked = false; + } + else { + this.blocked = true; + this.direction = 0; + + // When Ego is the blocked object also set ego's direction to zero. + if (this.objectNumber == 0) { + state.vars[Defines.EGODIR] = 0; + } + } + } + + /** + * Tests if the currently active block contains the given X/Y position. Ths method should + * not be called unless a block has been set. + * + * @param x The X position to test. + * @param y The Y position to test. + * + * @return + */ + private boolean inBlock(short x, short y) { + return (x > state.blockUpperLeftX && x < state.blockLowerRightX && y > state.blockUpperLeftY && y < state.blockLowerRightY); + } + + private static short[] xs = { 0, 0, 1, 1, 1, 0, -1, -1, -1 }; + private static short[] ys = { 0, -1, -1, 0, 1, 1, 1, 0, -1 }; + + /** + * Updates this AnimatedObject's position on the screen according to its current state. + */ + public void updatePosition() { + if (animated && update && drawn) { + // Decrement the move clock for this object. Don't move the object unless + // the clock has reached 0. + if ((stepTimeCount != 0) && (--stepTimeCount != 0)) return; + + // Reset the move clock. + stepTimeCount = stepTime; + + // Clear border collision flag. + byte border = 0; + + short ox = this.x; + short px = this.x; + short oy = this.y; + short py = this.y; + byte od = 0; + short os = 0; + + // If object has not been repositioned, move it. + if (!this.repositioned) { + od = this.direction; + os = (short)this.stepSize; + ox += (short)(xs[od] * os); + oy += (short)(ys[od] * os); + } + + // Check for object border collision. + if (ox < Defines.MINX) { + ox = Defines.MINX; + border = Defines.LEFT; + } + else if (ox + this.xSize() > Defines.MAXX + 1) { + ox = (short)(Defines.MAXX + 1 - this.xSize()); + border = Defines.RIGHT; + } + if (oy - this.ySize() < Defines.MINY - 1) { + oy = (short)(Defines.MINY - 1 + this.ySize()); + border = Defines.TOP; + } + else if (oy > Defines.MAXY) { + oy = Defines.MAXY; + border = Defines.BOTTOM; + } + else if (!ignoreHorizon && (oy <= state.horizon)) { + oy = (short)(state.horizon + 1); + border = Defines.TOP; + } + + // Update X and Y to the new position. + this.x = ox; + this.y = oy; + + // If object can't be in this position, then move back to previous + // position and clear the border collision flag + if (collide() || !canBeHere()) { + this.x = px; + this.y = py; + border = 0; + + // Make sure that this position is OK + findPosition(); + } + + // If the object hit the border, set the appropriate flags. + if (border > 0) { + if (this.objectNumber == 0) { + state.vars[Defines.EGOEDGE] = border; + } + else { + state.vars[Defines.OBJHIT] = this.objectNumber; + state.vars[Defines.OBJEDGE] = border; + } + + // If the object was on a 'moveobj', set the move as finished. + if (this.motionType == MotionType.MOVE_TO) { + endMoveObj(); + } + } + + // If object was not to be repositioned, it can be repositioned from now on. + this.repositioned = false; + } + } + + /** + * Return true if the object's position puts it on the screen; false otherwise. + * + * @return true if the object's position puts it on the screen; false otherwise. + */ + private boolean goodPosition() { + return ((this.x >= Defines.MINX) && ((this.x + this.xSize()) <= Defines.MAXX + 1) && + ((this.y - this.ySize()) >= Defines.MINY - 1) && (this.y <= Defines.MAXY) && + (this.ignoreHorizon || this.y > state.horizon)); + } + + /** + * Find a position for this AnimatedObject where it does not collide with any + * unappropriate objects or priority regions. If the object can't be in + * its current position, then start scanning in a spiral pattern for a position + * at which it can be placed. + */ + public void findPosition() { + // Place Y below horizon if it is above it and is not ignoring the horizon. + if ((this.y <= state.horizon) && !this.ignoreHorizon) { + this.y = (short)(state.horizon + 1); + } + + // If current position is OK, return. + if (goodPosition() && !collide() && canBeHere()) { + return; + } + + // Start scan. + int legLen = 1, legDir = 0, legCnt = 1; + + while (!goodPosition() || collide() || !canBeHere()) { + switch (legDir) { + case 0: // Move left. + --this.x; + + if (--legCnt == 0) + { + legDir = 1; + legCnt = legLen; + } + break; + + case 1: // Move down. + ++this.y; + + if (--legCnt == 0) + { + legDir = 2; + legCnt = ++legLen; + } + break; + + case 2: // Move right. + ++this.x; + + if (--legCnt == 0) + { + legDir = 3; + legCnt = legLen; + } + break; + + case 3: // Move up. + --this.y; + + if (--legCnt == 0) + { + legDir = 0; + legCnt = ++legLen; + } + break; + } + } + } + + /** + * Checks if this AnimatedObject has collided with another AnimatedObject. + * + * @return true if collided with another AnimatedObject; otherwise false. + */ + private boolean collide() { + // If AnimatedObject is ignoring objects this return false. + if (this.ignoreObjects) { + return false; + } + + for (AnimatedObject otherObj : state.animatedObjects) { + // Collision with another object if: + // - other object is animated and drawn + // - other object is not ignoring objects + // - other object is not this object + // - the two objects have overlapping baselines + if (otherObj.animated && otherObj.drawn && + !otherObj.ignoreObjects && + (this.objectNumber != otherObj.objectNumber) && + (this.x + this.xSize() >= otherObj.x) && + (this.x <= otherObj.x + otherObj.xSize())) + + // At this point, the two objects have overlapping + // x coordinates. A collision has occurred if they have + // the same y coordinate or if the object in question has + // moved across the other object in the last animation cycle + if ((this.y == otherObj.y) || + (this.y > otherObj.y && this.prevY < otherObj.prevY) || + (this.y < otherObj.y && this.prevY > otherObj.prevY)) { + + return true; + } + } + + return false; + } + + /** + * For the given y value, calculates what the priority value should be. + * + * @param y + * + * @return + */ + private byte calculatePriority(int y) { + return (byte)(y < state.priorityBase ? Defines.BACK_MOST_PRIORITY : (byte)(((y - state.priorityBase) / ((168.0 - state.priorityBase) / 10.0f)) + 5)); + } + + /** + * Return the effective Y for this Animated Object, which is Y if the priority is not fixed, or if it + * is fixed then is the value corresponding to the start of the fixed priority band. + * + */ + private short effectiveY() { + // IMPORTANT: When in fixed priority mode, it uses the "top" of the priority band, not the bottom, i.e. the "start" is the top. + return (fixedPriority ? (short)(state.priorityBase + Math.ceil(((168.0 - state.priorityBase) / 10.0f) * (priority - Defines.BACK_MOST_PRIORITY - 1))) : y); + } + + /** + * Checks if this AnimatedObject can be in its current position according to + * the control lines. Normally this method would be invoked immediately after + * setting its position to a newly calculated position. + * + * There are a number of side effects to calling this method, and in fact + * it is responsible for performing these updates: + * + * - It sets the priority value for the current Y position. + * - It sets the on.water flag, if applicable. + * - It sets the hit.special flag, if applicable. + * + * @return true if it can be in the current position; otherwise false. + */ + private boolean canBeHere() { + boolean canBeHere = true; + boolean entirelyOnWater = false; + boolean hitSpecial = false; + + // If the priority is not fixed, calculate the priority based on current Y position. + if (!this.fixedPriority) { + // NOTE: The following table only applies to games that don't support the ability to change the PriorityBase. + // Priority Band Y range + // ------------------------ + // 4 - + // 5 48 - 59 + // 6 60 - 71 + // 7 72 - 83 + // 8 84 - 95 + // 9 96 - 107 + // 10 108 - 119 + // 11 120 - 131 + // 12 132 - 143 + // 13 144 - 155 + // 14 156 - 167 + // 15 168 + // ------------------------ + this.priority = calculatePriority(this.y); + } + + // Priority 15 skips the whole base line testing. None of the control lines + // have any affect. + if (this.priority != 15) { + // Start by assuming we're on water. Will be set false if it turns out we're not. + entirelyOnWater = true; + + // Loop over the priority screen pixels for the area covered by this + // object's base line. + int startPixelPos = (y * 160) + x; + int endPixelPos = startPixelPos + xSize(); + + for (int pixelPos = startPixelPos; pixelPos < endPixelPos; pixelPos++) { + // Get the priority screen priority value for this pixel of the base line. + int priority = state.controlPixels[pixelPos]; + + if (priority != 3) { + // This pixel is not water (i.e. not 3), so it can't be entirely on water. + entirelyOnWater = false; + + if (priority == 0) { + // Permanent block. + canBeHere = false; + break; + } + else if (priority == 1) { + // Blocks if the AnimatedObject isn't ignoring blocks. + if (!ignoreBlocks) { + canBeHere = false; + break; + } + } + else if (priority == 2) { + hitSpecial = true; + } + } + } + + if (entirelyOnWater) { + if (this.stayOnLand) { + // Must not be entirely on water, so can't be here. + canBeHere = false; + } + } + else { + if (this.stayOnWater) { + canBeHere = false; + } + } + } + + // If the object is ego then we need to determine the on.water and hit.special flag values. + if (this.objectNumber == 0) { + state.flags[Defines.ONWATER] = entirelyOnWater; + state.flags[Defines.HITSPEC] = hitSpecial; + } + + return canBeHere; + } + + // Object views -- Same, Right, Left, Front, Back. + private static final byte S = 4; + private static final byte R = 0; + private static final byte L = 1; + private static final byte F = 2; + private static final byte B = 3; + private static byte[] twoLoop = { S, S, R, R, R, S, L, L, L }; + private static byte[] fourLoop = { S, B, R, R, R, F, L, L, L }; + + /** + * Updates the loop and cel numbers based on the AnimatedObjects current state. + */ + public void updateLoopAndCel() { + byte newLoop = 0; + + if (animated && update && drawn) { + // Get the appropriate loop based on the current direction. + newLoop = S; + + if (!fixedLoop) { + if (numberOfLoops() == 2 || numberOfLoops() == 3) { + newLoop = twoLoop[direction]; + } + else if (numberOfLoops() == 4) { + newLoop = fourLoop[direction]; + } + else if ((numberOfLoops() > 4) && (state.gameId.equals("KQ4"))) { + // Main Ego View (0) in KQ4 has 5 loops, but is expected to automatically change + // loop in sync with the Direction, in the same way as if it had only 4 loops. + newLoop = fourLoop[direction]; + } + } + + // If the object is to move in this cycle and the loop has changed, point to the new loop. + if ((stepTimeCount == 1) && (newLoop != S) && (currentLoop != newLoop)) { + setLoop(newLoop); + } + + // If it is time to cycle the object, advance it's cel. + if (cycle && (cycleTimeCount > 0) && (--cycleTimeCount == 0)) { + advanceCel(); + + cycleTimeCount = cycleTime; + } + } + } + + /** + * Determine which cel of an object to display next. + */ + public void advanceCel() { + int theCel; + int lastCel; + + if (noAdvance) { + noAdvance = false; + return; + } + + // Advance to the next cel in the loop. + theCel = currentCel; + lastCel = (numberOfCels() - 1); + + switch (cycleType) { + case NORMAL: + // Move to the next sequential cel. + if (++theCel > lastCel) { + theCel = 0; + } + break; + + case END_LOOP: + // Advance to the end of the loop, set flag in parms[0] when done + if (theCel >= lastCel || ++theCel == lastCel) { + state.flags[motionParam1] = true; + cycle = false; + direction = 0; + cycleType = CycleType.NORMAL; + } + break; + + case REVERSE_LOOP: + // Move backwards, celwise, until beginning of loop, then set flag. + if (theCel == 0 || --theCel == 0) { + state.flags[motionParam1] = true; + cycle = false; + direction = 0; + cycleType = CycleType.NORMAL; + } + break; + + case REVERSE: + // Cycle continually, but from end of loop to beginning. + if (theCel > 0) { + --theCel; + } + else { + theCel = lastCel; + } + break; + } + + // Get pointer to the new cel and set cel dimensions. + setCel(theCel); + } + + /** + * Adds this AnimatedObject as a permanent part of the current picture. If the priority parameter + * is 0, the object's priority is that of the priority band in which it is placed; otherwise it + * will be set to the specified priority value. If the controlBoxColour parameter is below 4, + * then a control line box is added to the control screen of the specified control colour value, + * which extends from the object's baseline to the bottom of the next lowest priority band. If + * this control box priority is set to 0, then obviously this would prevent animated objects from + * walking through it. The other 3 control colours have their normal behaviours as well. The + * add.to.pic objects ignore all control lines, all base lines of other objects, and the "block" + * if one is active... i.e. it can go anywhere in the picture. Once added, it is not animated + * and cannot be erased ecept by drawing something over it. It effectively becomes part of the + * picture. + * + * @param viewNum + * @param loopNum + * @param celNum + * @param x + * @param y + * @param priority + * @param controlBoxColour + * @param pixels + */ + public void addToPicture(int viewNum, int loopNum, int celNum, int x, int y, int priority, int controlBoxColour, short[] pixels) { + // Add the add.to.pic details to the script event buffer. + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.ADD_TO_PIC, 0, new byte[] { + (byte)viewNum, (byte)loopNum, (byte)celNum, (byte)x, (byte)y, (byte)(priority | (controlBoxColour << 4)) + }); + + // Set the view, loop, and cel to those specified. + setView(viewNum); + setLoop(loopNum); + setCel(celNum); + + // Set PreviousCel to current Cel for Show call. + this.previousCel = this.cel(); + + // Place the add.to.pic at the specified position. This may not be fully within the + // screen bounds, so a call below to FindPosition is made to resolve this. + this.x = this.prevX = (short)x; + this.y = this.prevY = (short)y; + + // In order to make use of FindPosition, we set these flags to disable certain parts + // of the FindPosition functionality that don't apply to add.to.pic objects. + this.ignoreHorizon = true; + this.fixedPriority = true; + this.ignoreObjects = true; + + // And we set the priority temporarily to 15 so that when FindPosition is doing its thing, + // the control lines will be ignored, as they have no effect on add.to.pic objects. + this.priority = 15; + + // Now we call FindPosition to adjust the object's position if it has been placed either + // partially or fully outside of the picture area. + findPosition(); + + // Having checked and (if appropriate) adjusted the position, we can now work out what the + // object priority should be. + if (priority == 0) { + // If the specified priority is 0, it means that the priority should be calculated + // from the object's Y position as would normally happen if its priority is not fixed. + this.priority = calculatePriority(this.y); + } + else { + // Otherwise it will be set to the specified value. + this.priority = (byte)priority; + } + + this.controlBoxColour = (byte)controlBoxColour; + + // Draw permanently to the CurrentPicture, including the control box. + draw(state.currentPicture); + + // Restore backgrounds, add add.to.pic to VisualPixels, then redraw AnimatedObjects and show updated area. + state.restoreBackgrounds(); + draw(); + state.drawObjects(); + show(pixels); + } + + /** + * Set the Cel of this AnimatedObject to the given cel number. + * + * @param celNum The cel number within the current Loop to set the Cel to. + */ + public void setCel(int celNum) { + // Set the cel number. + this.currentCel = celNum; + + // The border collision can only be performed if a valid combination of loops and cels has been set. + if ((this.currentLoop < this.numberOfLoops()) && (this.currentCel < this.numberOfCels())) { + // Make sure that the new cel size doesn't cause a border collision. + if (this.x + this.xSize() > Defines.MAXX + 1) { + // Don't let the object move. + this.repositioned = true; + this.x = (short)(Defines.MAXX - this.xSize()); + } + + if (this.y - this.ySize() < Defines.MINY - 1) { + this.repositioned = true; + this.y = (short)(Defines.MINY - 1 + this.ySize()); + + if (this.y <= state.horizon && !this.ignoreHorizon) { + this.y = (short)(state.horizon + 1); + } + } + } + } + + /** + * Set the loop of this AnimatedObject to the given loop number. + * + * @param loopNum The loop number within the current View to set the Loop to. + */ + public void setLoop(int loopNum) { + this.currentLoop = loopNum; + + // If the current cel # is greater than the cel count for this loop, set + // it to 0, otherwise leave it alone. Sometimes the loop number is set before + // the associated view number is set. We allow for this in the check below. + if ((this.currentLoop >= this.numberOfLoops()) || (this.currentCel >= this.numberOfCels())) { + this.currentCel = 0; + } + + this.setCel(this.currentCel); + } + + /** + * Set the number of the View for this AnimatedObject to use. + * + * @param viewNum The number of the View for this AnimatedObject to use. + */ + public void setView(int viewNum) { + this.currentView = viewNum; + + // If the current loop is greater than the number of loops for the view, + // set the loop number to 0. Otherwise, leave it alone. + setLoop(currentLoop >= numberOfLoops()? 0 : currentLoop); + } + + /** + * Performs an animate.obj on this AnimatedObject. + */ + public void animate() { + if (!animated) { + // Most flags are reset to false. + this.ignoreBlocks = false; + this.fixedPriority = false; + this.ignoreHorizon = false; + this.cycle = false; + this.blocked = false; + this.stayOnLand = false; + this.stayOnWater = false; + this.ignoreObjects = false; + this.repositioned = false; + this.noAdvance = false; + this.fixedLoop = false; + this.stopped = false; + + // But these ones are specifying set to true. + this.animated = true; + this.update = true; + this.cycle = true; + + this.motionType = MotionType.NORMAL; + this.cycleType = CycleType.NORMAL; + this.direction = 0; + } + } + + /** + * Repositions the object by the deltaX and deltaY values. + * + * @param deltaX Delta for the X position (signed, where negative is to the left) + * @param deltaY Delta for the Y position (signed, where negative is to the top) + */ + public void reposition(int deltaX, int deltaY) { + this.repositioned = true; + + if ((deltaX < 0) && (this.x < -deltaX)) { + this.x = 0; + } + else { + this.x = (short)(this.x + deltaX); + } + + if ((deltaY < 0) && (this.y < -deltaY)) { + this.y = 0; + } + else { + this.y = (short)(this.y + deltaY); + } + + // Make sure that this position is OK + findPosition(); + } + + /** + * Calculates the distance between this AnimatedObject and the given AnimatedObject. + * + * @param aniObj The AnimatedObject to calculate the distance to. + * + * @return + */ + public int distance(AnimatedObject aniObj) { + if (!this.drawn || !aniObj.drawn) { + return Defines.MAXVAR; + } + else { + int dist = Math.abs((this.x + this.xSize() / 2) - (aniObj.x + aniObj.xSize() / 2)) + Math.abs(this.y - aniObj.y); + return ((dist > 254) ? 254 : dist); + } + } + + /** + * Draws this AnimatedObject to the pixel arrays of the given Picture. This is intended for use by + * add.to.pic objects, which is a specialist static type of AnimatedObject that becomes a permanent + * part of the Picture. + * + * @param picture + */ + public void draw(Picture picture) { + Cel cel = cel(); + int cellWidth = cel.getWidth(); + int cellHeight = cel.getHeight(); + + // The cellPixels array is already in ARGB format. + int[] cellPixels = cel.getPixelData(); + + // The visualPixels array is already in ARGB format. + int[] visualPixels = picture.getVisualPixels(); + + // The priorityPixels array is in index format (i.e. 0-15) + int[] priorityPixels = picture.getPriorityPixels(); + + // Get the transparency colour. We'll use this to ignore pixels this colour. + int transparentPixelRGB = this.cel().getTransparentPixel(); + + // Calculate starting position within the pixel arrays. + int aniObjTop = ((this.y - cellHeight) + 1); + int screenPos = (aniObjTop * 160) + this.x; + int screenLineAdd = 160 - cellWidth; + int cellPos = 0; + int cellXAdd = 1; + int cellYAdd = 0;; + + // Iterate over each of the pixels and decide if the priority screen allows the pixel + // to be drawn or not when adding them in to the VisualPixels and PriorityPixels arrays. + for (int y = 0; y < cellHeight; y++, screenPos += screenLineAdd, cellPos += cellYAdd) { + for (int x = 0; x < cellWidth; x++, screenPos++, cellPos += cellXAdd) { + // Check that the pixel is within the bounds of the AGI picture area. + if (((aniObjTop + y) >= 0) && ((aniObjTop + y) < 168) && ((this.x + x) >= 0) && ((this.x + x) < 160)) { + // Get the priority colour index for this position from the priority screen. + int priorityIndex = priorityPixels[screenPos]; + + // If this AnimatedObject's priority is greater or equal to the priority screen value + // for this pixel's position, then we'll draw it. + if (this.priority >= priorityIndex) { + // Get the colour index from the Cell bitmap pixels. + int cellPixelRGB = cellPixels[cellPos]; + + // If the colourIndex is not the transparent index, then we'll draw the pixel. + if (cellPixelRGB != transparentPixelRGB) { + visualPixels[screenPos] = cellPixelRGB; + // Replace the priority pixel only if the existing one is not a special priority pixel (0, 1, 2) + if (priorityIndex > 2) { + priorityPixels[screenPos] = this.priority; + } + } + } + } + } + } + + // Draw the control box. + if (controlBoxColour <= 3) { + // Calculate the height of the box. + int yy = this.y; + byte priorityHeight = 0; + byte objPriorityForY = calculatePriority(this.y); + do { + priorityHeight++; + if (yy <= 0) break; + yy--; + } + while (calculatePriority(yy) == objPriorityForY); + int height = (ySize() > priorityHeight ? priorityHeight : ySize()); + + // Draw bottom line. + for (int i = 0; i < xSize(); i++) { + priorityPixels[(this.y * 160) + this.x + i] = controlBoxColour; + } + + if (height > 1) { + // Draw both sides. + for (int i = 1; i < height; i++) { + priorityPixels[((this.y - i) * 160) + this.x] = controlBoxColour; + priorityPixels[((this.y - i) * 160) + this.x + xSize() - 1] = controlBoxColour; + } + + // Draw top line. + for (int i = 1; i < xSize() - 1; i++) { + priorityPixels[((this.y - (height - 1)) * 160) + this.x + i] = controlBoxColour; + } + } + } + } + + /** + * Draws this AnimatedObject to the VisualPixels pixels array. + */ + public void draw() { + Cel cel = cel(); + int cellWidth = cel.getWidth(); + int cellHeight = cel.getHeight(); + int[] cellPixels = cel.getPixelData(); + + // Get the transparency colour. We'll use this to ignore pixels this colour. + int transparentPixelRGB = cel.getTransparentPixel(); + + // Calculate starting screen offset. AGI pixels are 2x1 within the picture area. + int aniObjTop = ((this.y - cellHeight) + 1); + int screenPos = (aniObjTop * 320) + (this.x * 2); + int screenLineAdd = 320 - (cellWidth << 1); + + // Calculate starting position within the priority screen. + int priorityPos = (aniObjTop * 160) + this.x; + int priorityLineAdd = 160 - cellWidth; + int cellPos = 0; + int cellXAdd = 1; + int cellYAdd = 0; + + // Allocate new background pixel array for the current cell size. + this.saveArea.visBackPixels = new short[cellWidth][cellHeight]; + this.saveArea.priBackPixels = new int[cellWidth][cellHeight]; + this.saveArea.x = this.x; + this.saveArea.y = this.y; + this.saveArea.width = cellWidth; + this.saveArea.height = cellHeight; + + // Iterate over each of the pixels and decide if the priority screen allows the pixel + // to be drawn or not. Deliberately tried to avoid multiplication within the loops. + for (int y = 0; y < cellHeight; y++, screenPos += screenLineAdd, priorityPos += priorityLineAdd, cellPos += cellYAdd) { + for (int x = 0; x < cellWidth; x++, screenPos += 2, priorityPos++, cellPos += cellXAdd) { + // Check that the pixel is within the bounds of the AGI picture area. + if (((aniObjTop + y) >= 0) && ((aniObjTop + y) < 168) && ((this.x + x) >= 0) && ((this.x + x) < 160)) { + // Store the background pixel. Should be the same colour in both pixels. + this.saveArea.visBackPixels[x][y] = state.visualPixels[screenPos]; + this.saveArea.priBackPixels[x][y] = state.priorityPixels[priorityPos]; + + // Get the priority colour index for this position from the priority screen. + int priorityIndex = state.priorityPixels[priorityPos]; + + // If this AnimatedObject's priority is greater or equal to the priority screen value + // for this pixel's position, then we'll draw it. + if (this.priority >= priorityIndex) { + // Get the colour index from the Cell bitmap pixels. + int cellPixelRGB = cellPixels[cellPos]; + + // If the colourIndex is not the transparent index, then we'll draw the pixel. + if (cellPixelRGB != transparentPixelRGB) { + // Get the RGB565 value from the AGI Color Palette. + short colorRGB565 = EgaPalette.RGB888_TO_RGB565_MAP.get(cellPixelRGB); + + // Draw two pixels (due to AGI picture pixels being 2x1). + state.visualPixels[screenPos] = colorRGB565; + state.visualPixels[screenPos + 1] = colorRGB565; + + // Priority screen is only stored 160x168 though. + state.priorityPixels[priorityPos] = this.priority; + } + } + } + } + } + } + + /** + * Restores the current background pixels to the previous position of this AnimatedObject. + */ + public void restoreBackPixels() { + if ((saveArea.visBackPixels != null) && (saveArea.priBackPixels != null)) { + int saveWidth = saveArea.width; + int saveHeight = saveArea.height; + int aniObjTop = ((saveArea.y - saveHeight) + 1); + int screenPos = (aniObjTop * 320) + (saveArea.x * 2); + int screenLineAdd = 320 - (saveWidth << 1); + int priorityPos = (aniObjTop * 160) + saveArea.x; + int priorityLineAdd = 160 - saveWidth; + + for (int y = 0; y < saveHeight; y++, screenPos += screenLineAdd, priorityPos += priorityLineAdd) { + for (int x = 0; x < saveWidth; x++, screenPos += 2, priorityPos++) { + if (((aniObjTop + y) >= 0) && ((aniObjTop + y) < 168) && ((saveArea.x + x) >= 0) && ((saveArea.x + x) < 160)) { + state.visualPixels[screenPos] = saveArea.visBackPixels[x][y]; + state.visualPixels[screenPos + 1] = saveArea.visBackPixels[x][y]; + state.priorityPixels[priorityPos] = saveArea.priBackPixels[x][y]; + } + } + } + } + } + + /** + * Shows the AnimatedObject by blitting the bounds of its current and previous cels to the screen + * pixels. The include the previous cel so that we pick up the restoration of the save area. + * + * @param pixels The screen pixels to blit the AnimatedObject to. + */ + public void show(short[] pixels) { + // We will only render an AnimatedObject to the screen if the picture is currently visible. + if (state.pictureVisible) { + // Work out the rectangle that covers the previous and current cells. + int prevCelWidth = (this.previousCel != null ? this.previousCel.getWidth() : this.xSize()); + int prevCelHeight = (this.previousCel != null? this.previousCel.getHeight() : this.ySize()); + int prevX = (this.previousCel != null ? this.prevX : this.x); + int prevY = (this.previousCel != null ? this.prevY : this.y); + int leftmostX = Math.min(prevX, this.x); + int rightmostX = Math.max(prevX + prevCelWidth, this.x + this.xSize()) - 1; + int topmostY = Math.min(prevY - prevCelHeight, this.y - this.ySize()) + 1; + int bottommostY = Math.max(prevY, this.y); + + // We no longer need the PreviousCel, so point it at the new one. + this.previousCel = this.cel(); + + int height = (bottommostY - topmostY) + 1; + int width = ((rightmostX - leftmostX) + 1) * 2; + int picturePos = (topmostY * 320) + (leftmostX * 2); + int pictureLineAdd = 320 - width; + int screenPos = picturePos + (state.pictureRow * 8 * 320); + + for (int y = 0; y < height; y++, picturePos += pictureLineAdd, screenPos += pictureLineAdd) { + for (int x = 0; x < width; x++, screenPos++, picturePos++) { + if (((topmostY + y) >= 0) && ((topmostY + y) < 168) && ((leftmostX + x) >= 0) && ((leftmostX + x) < 320) && (screenPos >= 0) && (screenPos < pixels.length)) { + pixels[screenPos] = state.visualPixels[picturePos]; + } + } + } + } + } + + /** + * Used to sort by drawing order when drawing AnimatedObjects to the screen. When + * invoked, it compares the other AnimatedObject with this one and says which is in + * front and which is behind. Since we want to draw those with lowest priority first, + * and if their priority is equal then lowest Y, then this is what determines whether + * we return a negative value, equal, or greater. + * + * @param other The other AnimatedObject to compare this one to. + */ + public int compareTo(AnimatedObject other) { + if (this.priority < other.priority) { + return -1; + } + else if (this.priority > other.priority) { + return 1; + } + else { + if (this.effectiveY() < other.effectiveY()) { + return -1; + } + else if (this.effectiveY() > other.effectiveY()) { + return 1; + } + else { + return 0; + } + } + } + + /** + * Gets the core status of the object in the status string format used by the AGI + * debug mode. + */ + public String getStatusStr() { + return String.format( + "Object %d:\nx: %d xsize: %d\ny: %d ysize: %d\npri: %d\nstepsize: %d", + objectNumber, x, xSize(), y, ySize(), priority, stepSize); + } + + /** + * An enum that defines the types of motion that an AnimatedObject can have. + */ + public enum MotionType { + + /** + * AnimatedObject is using the normal motion. + */ + NORMAL, + + /** + * AnimatedObject randomly moves around the screen. + */ + WANDER, + + /** + * AnimatedObject follows another AnimatedObject. + */ + FOLLOW, + + /** + * AnimatedObject is moving to a given coordinate. + */ + MOVE_TO + } + + /** + * An enum that defines the type of cel cycling that an AnimatedObject can have. + */ + public enum CycleType { + + /** + * Normal repetitive cycling of the AnimatedObject. + */ + NORMAL, + + /** + * Cycle to the end of the loop and then stop. + */ + END_LOOP, + + /** + * Cycle in reverse order to the start of the loop and then stop. + */ + REVERSE_LOOP, + + /** + * Cycle continually in reverse. + */ + REVERSE + } +} diff --git a/core/src/main/java/com/agifans/agile/Character.java b/core/src/main/java/com/agifans/agile/Character.java new file mode 100644 index 0000000..a72e19b --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Character.java @@ -0,0 +1,212 @@ +package com.agifans.agile; + +import java.util.HashMap; +import java.util.Map; + +import com.badlogic.gdx.Input.Keys; + +/** + * The AGI interpreter uses standard ASCII keycodes. This class is used to map + * the libgdx keystrokes to standard ASCII and then to provide constants for use + * within the AGILE interpreter. This includes CTRL key combinations that would + * result in an ASCI character, e.g. CTRL-I being the same as TAB. + * + * Many of the libgdx characters result in the keyTyped of the InputAdapter being + * invoked, but there are some that do not, such as the CTRL key combinations, but + * also ESC. We let keyTyped handle the ones that it can, but for those that it + * can't, we provide the mapping so that keyDown can enqueue it instead. + */ +public class Character { + + /** + * The CTRL modifier key. + */ + private static final int CONTROL_MODIFIER = 0x20000; + + // ASCII characters + public static final int CTRL_A = 1; + public static final int CTRL_B = 2; + public static final int CTRL_C = 3; + public static final int CTRL_D = 4; + public static final int CTRL_E = 5; + public static final int CTRL_F = 6; + public static final int CTRL_G = 7; + public static final int CTRL_H = 8; + public static final int BACKSPACE = 8; + public static final int CTRL_I = 9; + public static final int TAB = 9; + public static final int CTRL_J = 10; + public static final int CTRL_ENTER = 10; + public static final int CTRL_K = 11; + public static final int CTRL_L = 12; + public static final int CTRL_M = 13; + public static final int ENTER = 13; + public static final int CTRL_N = 14; + public static final int CTRL_O = 15; + public static final int CTRL_P = 16; + public static final int CTRL_Q = 17; + public static final int CTRL_R = 18; + public static final int CTRL_S = 19; + public static final int CTRL_T = 20; + public static final int CTRL_U = 21; + public static final int CTRL_V = 22; + public static final int CTRL_W = 23; + public static final int CTRL_X = 24; + public static final int CTRL_Y = 25; + public static final int CTRL_Z = 26; + + public static final int ESC = 27; + + public static final int CTRL_BACK_SLASH = 28; + public static final int CTRL_CLOSE_SQUARE_BRACKET = 29; + public static final int CTRL_6 = 30; + public static final int CTRL_MINUS = 31; + + public static final int SPACE = 32; + public static final int EXCLAIMATION_MARK = 33; + public static final int DOUBLE_QUOTE = 34; + public static final int HASH = 35; + public static final int DOLLAR_SIGN = 36; + public static final int PERCENTAGE_SIGN = 37; + public static final int AMPERSAND = 38; + public static final int APOSTROPHE = 39; + public static final int OPEN_BACKET = 40; + public static final int CLOSE_BRACKET = 41; + public static final int ASTERISK = 42; + public static final int PLUS_SIGN = 43; + public static final int COMMA = 44; + public static final int MINUS_SIGN = 45; + public static final int PERIOD = 46; + public static final int FORWARD_SLASH = 47; + + public static final int NUM_0 = 48; + public static final int NUM_1 = 49; + public static final int NUM_2 = 50; + public static final int NUM_3 = 51; + public static final int NUM_4 = 52; + public static final int NUM_5 = 53; + public static final int NUM_6 = 54; + public static final int NUM_7 = 55; + public static final int NUM_8 = 56; + public static final int NUM_9 = 57; + + public static final int COLON = 58; + public static final int SEMI_COLON = 59; + public static final int LESS_THAN = 60; + public static final int EQUALS = 61; + public static final int GREATER_THAN = 62; + public static final int QUESTION_MARK = 63; + public static final int AT_SIGN = 64; + + public static final int UPPER_A = 65; + public static final int UPPER_B = 66; + public static final int UPPER_C = 67; + public static final int UPPER_D = 68; + public static final int UPPER_E = 69; + public static final int UPPER_F = 70; + public static final int UPPER_G = 71; + public static final int UPPER_H = 72; + public static final int UPPER_I = 73; + public static final int UPPER_J = 74; + public static final int UPPER_K = 75; + public static final int UPPER_L = 76; + public static final int UPPER_M = 77; + public static final int UPPER_N = 78; + public static final int UPPER_O = 79; + public static final int UPPER_P = 80; + public static final int UPPER_Q = 81; + public static final int UPPER_R = 82; + public static final int UPPER_S = 83; + public static final int UPPER_T = 84; + public static final int UPPER_U = 85; + public static final int UPPER_V = 86; + public static final int UPPER_W = 87; + public static final int UPPER_X = 88; + public static final int UPPER_Y = 89; + public static final int UPPER_Z = 90; + + public static final int OPEN_SQUARE_BRACKET = 91; + public static final int BACK_SLASH = 92; + public static final int CLOSE_SQUARE_BRACKET = 93; + public static final int CARAT = 94; + public static final int UNDERSCORE = 95; + public static final int BACK_TICK = 96; + + public static final int LOWER_A = 97; + public static final int LOWER_B = 98; + public static final int LOWER_C = 99; + public static final int LOWER_D = 100; + public static final int LOWER_E = 101; + public static final int LOWER_F = 102; + public static final int LOWER_G = 103; + public static final int LOWER_H = 104; + public static final int LOWER_I = 105; + public static final int LOWER_J = 106; + public static final int LOWER_K = 107; + public static final int LOWER_L = 108; + public static final int LOWER_M = 109; + public static final int LOWER_N = 110; + public static final int LOWER_O = 111; + public static final int LOWER_P = 112; + public static final int LOWER_Q = 113; + public static final int LOWER_R = 114; + public static final int LOWER_S = 115; + public static final int LOWER_T = 116; + public static final int LOWER_U = 117; + public static final int LOWER_V = 118; + public static final int LOWER_W = 119; + public static final int LOWER_X = 120; + public static final int LOWER_Y = 121; + public static final int LOWER_Z = 122; + + public static final int OPEN_BRACE = 123; + public static final int PIPE = 124; + public static final int CLOSE_BRACE = 125; + public static final int TILDA = 126; + + + public static final Map KEYSTROKE_TO_CHAR_MAP = new HashMap<>(); + static { + + // LibGDX does not translate CTRL combinations into "keyTyped" calls. + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.A, CTRL_A); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.B, CTRL_B); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.C, CTRL_C); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.D, CTRL_D); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.E, CTRL_E); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.F, CTRL_F); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.G, CTRL_G); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.H, CTRL_H); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.I, CTRL_I); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.J, CTRL_J); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.ENTER, CTRL_ENTER); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.K, CTRL_K); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.L, CTRL_L); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.M, CTRL_M); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.N, CTRL_N); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.O, CTRL_O); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.P, CTRL_P); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.Q, CTRL_Q); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.R, CTRL_R); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.S, CTRL_S); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.T, CTRL_T); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.U, CTRL_U); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.V, CTRL_V); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.W, CTRL_W); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.X, CTRL_X); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.Y, CTRL_Y); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.Z, CTRL_Z); + + // ENTER goes through to keyTyped as 0x0A, i.e. LF!! So we map this ourselves to CR. + KEYSTROKE_TO_CHAR_MAP.put(Keys.ENTER, ENTER); + + // ESC does not pass through to keyTyped either. + KEYSTROKE_TO_CHAR_MAP.put(Keys.ESCAPE, ESC); + + + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.BACKSLASH, CTRL_BACK_SLASH); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.LEFT_BRACKET, CTRL_CLOSE_SQUARE_BRACKET); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.NUM_6, CTRL_6); + KEYSTROKE_TO_CHAR_MAP.put(CONTROL_MODIFIER + Keys.MINUS, CTRL_MINUS); + } +} diff --git a/core/src/main/java/com/agifans/agile/Commands.java b/core/src/main/java/com/agifans/agile/Commands.java new file mode 100644 index 0000000..d5b8586 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Commands.java @@ -0,0 +1,2147 @@ +package com.agifans.agile; + +import com.agifans.agile.AnimatedObject.CycleType; +import com.agifans.agile.AnimatedObject.MotionType; +import com.agifans.agile.ScriptBuffer.ScriptBufferEvent; +import com.agifans.agile.agilib.Logic; +import com.agifans.agile.agilib.Logic.Action; +import com.agifans.agile.agilib.Logic.Condition; +import com.agifans.agile.agilib.Logic.GotoAction; +import com.agifans.agile.agilib.Logic.IfAction; +import com.agifans.agile.agilib.Picture; +import com.agifans.agile.agilib.Sound; +import com.agifans.agile.agilib.View; + +/** + * Performs the execution of an AGI Logic script. + */ +public class Commands { + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * Holds the data and state for the user input, i.e. keyboard and mouse input. + */ + private UserInput userInput; + + /** + * The pixels array for the AGI screen on which the background Picture and + * AnimatedObjects will be drawn to. + */ + private short[] pixels; + + /** + * Provides methods for drawing text on to the AGI screen. + */ + private TextGraphics textGraphics; + + /** + * Responsible for parsing the user input line to match known words + */ + private Parser parser; + + /** + * Responsible for displaying the inventory screen. + */ + private Inventory inventory; + + /** + * Responsible for displaying the menu system. + */ + private Menu menu; + + /** + * Responsible for saving and restoring saved game files. + */ + private SavedGames savedGames; + + /** + * Responsible for playing Sound resources. + */ + private SoundPlayer soundPlayer; + + /** + * Constructor for Commands. + * + * @param pixels + * @param state + * @param userInput + * @param textGraphics + * @param parser + * @param soundPlayer + * @param menu + */ + public Commands(short[] pixels, GameState state, UserInput userInput, TextGraphics textGraphics, Parser parser, SoundPlayer soundPlayer, Menu menu) { + this.pixels = pixels; + this.state = state; + this.userInput = userInput; + this.textGraphics = textGraphics; + this.parser = parser; + this.menu = menu; + this.inventory = new Inventory(state, userInput, textGraphics, pixels); + this.savedGames = new SavedGames(state, userInput, textGraphics, pixels); + this.soundPlayer = soundPlayer; + } + + /** + * Draws the AGI Picture identified by the given picture number. + * + * @param pictureNum The number of the picture to draw. + */ + private void drawPicture(int pictureNum) { + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.DRAW_PIC, pictureNum); + state.restoreBackgrounds(); + + // We create a clone of the Picture so that is drawing state isn't persisted + // back to the master list of pictures in the GameState. + Picture picture = state.pictures[pictureNum].clone(); + picture.drawPicture(); + + state.currentPicture = picture; + + updatePixelArrays(); + + state.drawObjects(); + + state.pictureVisible = false; + } + + /** + * Updates the Visual, Priority and Control pixel arrays with the bitmaps from the + * current Picture. + */ + private void updatePixelArrays() { + Picture picture = state.currentPicture; + + int[] visualPixels = picture.getVisualPixels(); + + // Copy the pixels to our VisualPixels array, doubling each one as we go. + for (int i = 0, ii = 0; i < (160 * 168); i++, ii += 2) { + // NOTE: Visual pixel array in JAGI is in RGB888 format + short rgb565Color = EgaPalette.RGB888_TO_RGB565_MAP.get(visualPixels[i]); + state.visualPixels[ii + 0] = rgb565Color; + state.visualPixels[ii + 1] = rgb565Color; + } + + splitPriorityPixels(); + } + + /** + * Overlays an AGI Picture identified by the given picture number over the current picture. + * + * @param pictureNum + */ + private void overlayPicture(int pictureNum) { + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.OVERLAY_PIC, pictureNum); + state.restoreBackgrounds(); + + // Draw the overlay picture on top of the current picture. + Picture overlayPicture = state.pictures[pictureNum]; + state.currentPicture.overlayPicture(overlayPicture); + + updatePixelArrays(); + + state.drawObjects(); + + showVisualPixels(); + + state.pictureVisible = false; + } + + /** + * For the current picture, sets the relevant pixels in the PriorityPixels and + * ControlPixels arrays in the GameState. It determines the priority information for + * pixels that are overdrawn by control lines by the same method used in Sierra's + * interpreter. To quote the original AGI specs: "Control pixels still have a visual + * priority from 4 to 15. To accomplish this, AGI scans directly down the control + * priority until it finds some 'non-control' priority". + */ + private void splitPriorityPixels() { + Picture picture = state.currentPicture; + int[] priorityPixels = picture.getPriorityPixels(); + + for (int x = 0; x < 160; x++) { + for (int y = 0; y < 168; y++) { + // Shift left 7 + shift level 5 is a trick to avoid multiplying by 160. + int index = (y << 7) + (y << 5) + x; + int data = priorityPixels[index]; + + if (data == 3) { + state.priorityPixels[index] = 3; + state.controlPixels[index] = data; + } + else if (data < 3) { + state.controlPixels[index] = data; + + int dy = y + 1; + boolean priFound = false; + + while (!priFound && (dy < 168)) { + data = priorityPixels[(dy << 7) + (dy << 5) + x]; + + if (data > 2) { + priFound = true; + state.priorityPixels[index] = data; + } + else { + dy++; + } + } + } + else { + state.controlPixels[index] = 4; + state.priorityPixels[index] = data; + } + } + } + } + + /** + * Shows the current priority pixels and control pixels to screen. + */ + public void showPriorityScreen() { + short[] backPixels = new short[pixels.length]; + + System.arraycopy(pixels, 0, backPixels, 0, pixels.length); + + for (int i = 0, ii = (8 * state.pictureRow) * 320; i < (160 * 168); i++, ii += 2) { + int priColorIndex = state.priorityPixels[i]; + int ctrlColorIndex = state.controlPixels[i]; + short rgb565Color = EgaPalette.colours[ctrlColorIndex <= 3 ? ctrlColorIndex : priColorIndex]; + pixels[ii + 0] = rgb565Color; + pixels[ii + 1] = rgb565Color; + } + + userInput.waitForKey(true); + + System.arraycopy(backPixels, 0, pixels, 0, pixels.length); + } + + /** + * Blits the current VisualPixels array to the screen pixels array. + */ + private void showVisualPixels() { + // Perform the copy to the pixels array of the VisualPixels. This is where the PictureRow comes in to effect. + System.arraycopy(state.visualPixels, 0, this.pixels, (8 * state.pictureRow) * 320, state.visualPixels.length); + } + + /** + * Implements the show.pic command. Blits the current VisualPixels array to the screen pixels + * array. If there is an open window, it will be closed by default. + */ + private void showPicture() { + showPicture(true); + } + + /** + * Implements the show.pic command. Blits the current VisualPixels array to the screen pixels + * array. If there is an open window, the closeWindow parameter determines when to close the + * window. + * + * @param closeWindow Skips the closing of open windows if set to false. + */ + private void showPicture(boolean closeWindow) { + if (closeWindow) { + // It is possible to leave the window up from the previous room, so we force a close. + state.flags[Defines.LEAVE_WIN] = false; + textGraphics.closeWindow(false); + } + + // Perform the copy to the pixels array of the VisualPixels + showVisualPixels(); + + // Remember that the picture is now being displayed to the user. + state.pictureVisible = true; + } + + /** + * Executes the shake.screen command. Implementation is based on the scummvm code. + * + * @param repeatCount The number of times to do the shake routine. + */ + private void shakeScreen(int repeatCount) { + int shakeCount = (repeatCount * 8); + short backgroundRGB565 = EgaPalette.colours[0]; + short[] backPixels = new short[pixels.length]; + + System.arraycopy(pixels, 0, backPixels, 0, pixels.length); + + for (int shakeNumber = 0; shakeNumber < shakeCount; shakeNumber++) { + if ((shakeNumber & 1) == 1) { + System.arraycopy(backPixels, 0, pixels, 0, pixels.length); + } + else { + for (int y = 0, screenPos = 0; y < 200; y++) { + for (int x = 0; x < 320; x++, screenPos++) { + if ((x < 8) || (y < 4)) { + this.pixels[screenPos] = backgroundRGB565; + } + else { + this.pixels[screenPos] = backPixels[screenPos - 1288]; + } + } + } + } + try { + Thread.sleep(66); + } catch (InterruptedException e) { + // Ignore. + } + } + + System.arraycopy(backPixels, 0, pixels, 0, pixels.length); + } + + /** + * Replays the events that happened in the ScriptBuffer. This would usually be called + * immediately after restoring a saved game file, to do things such as add the add.to.pics, + * draw the picture, show the picture, etc. + */ + private void replayScriptEvents() { + // Mainly for the AddToPicture method, since that adds script events if active. + state.scriptBuffer.scriptOff(); + + for (ScriptBufferEvent scriptBufferEvent : state.scriptBuffer.events) { + switch (scriptBufferEvent.type) { + case ADD_TO_PIC: + { + AnimatedObject picObj = new AnimatedObject(state, -1); + picObj.addToPicture( + (scriptBufferEvent.data[0] & 0xFF), + (scriptBufferEvent.data[1] & 0xFF), + (scriptBufferEvent.data[2] & 0xFF), + (scriptBufferEvent.data[3] & 0xFF), + (scriptBufferEvent.data[4] & 0xFF), + (scriptBufferEvent.data[5] & 0x0F), + ((scriptBufferEvent.data[5] >> 4) & 0x0F), + pixels); + splitPriorityPixels(); + } + break; + + case DISCARD_PIC: + { + Picture pic = state.pictures[scriptBufferEvent.resourceNumber]; + if (pic != null) pic.isLoaded = false; + } + break; + + case DISCARD_VIEW: + { + View view = state.views[scriptBufferEvent.resourceNumber]; + if (view != null) view.isLoaded = false; + } + break; + + case DRAW_PIC: + { + drawPicture(scriptBufferEvent.resourceNumber); + } + break; + + case LOAD_LOGIC: + { + Logic logic = state.logics[scriptBufferEvent.resourceNumber]; + if (logic != null) logic.isLoaded = true; + } + break; + + case LOAD_PIC: + { + Picture pic = state.pictures[scriptBufferEvent.resourceNumber]; + if (pic != null) pic.isLoaded = true; + } + break; + + case LOAD_SOUND: + { + Sound sound = state.sounds[scriptBufferEvent.resourceNumber]; + if (sound != null) + { + soundPlayer.loadSound(sound); + sound.isLoaded = true; + } + } + break; + + case LOAD_VIEW: + { + View view = state.views[scriptBufferEvent.resourceNumber]; + if (view != null) view.isLoaded = true; + } + break; + + case OVERLAY_PIC: + { + overlayPicture(scriptBufferEvent.resourceNumber); + } + break; + } + } + + state.scriptBuffer.scriptOn(); + } + + /** + * Evaluates the given Condition. + * + * @param condition The Condition to evaluate. + * + * @return The result of evaluating the Condition; either true or false. + */ + private boolean isConditionTrue(Condition condition) { + boolean result = false; + + switch (condition.operation.opcode) { + + case 1: // equaln + { + result = (state.vars[condition.operands.get(0).asByte()] == condition.operands.get(1).asByte()); + } + break; + + case 2: // equalv + { + result = (state.vars[condition.operands.get(0).asByte()] == state.vars[condition.operands.get(1).asByte()]); + } + break; + + case 3: // lessn + { + result = (state.vars[condition.operands.get(0).asByte()] < condition.operands.get(1).asByte()); + } + break; + + case 4: // lessv + { + result = (state.vars[condition.operands.get(0).asByte()] < state.vars[condition.operands.get(1).asByte()]); + } + break; + + case 5: // greatern + { + result = (state.vars[condition.operands.get(0).asByte()] > condition.operands.get(1).asByte()); + } + break; + + case 6: // greaterv + { + result = (state.vars[condition.operands.get(0).asByte()] > state.vars[condition.operands.get(1).asByte()]); + } + break; + + case 7: // isset + { + result = state.flags[condition.operands.get(0).asByte()]; + } + break; + + case 8: // issetv + { + result = state.flags[state.vars[condition.operands.get(0).asByte()]]; + } + break; + + case 9: // has + { + result = (state.objects.objects.get(condition.operands.get(0).asByte()).room == Defines.CARRYING); + } + break; + + case 10: // obj.in.room + { + result = (state.objects.objects.get(condition.operands.get(0).asByte()).room == state.vars[condition.operands.get(1).asByte()]); + } + break; + + case 11: // posn + { + AnimatedObject aniObj = state.animatedObjects[condition.operands.get(0).asByte()]; + int x1 = condition.operands.get(1).asByte(); + int y1 = condition.operands.get(2).asByte(); + int x2 = condition.operands.get(3).asByte(); + int y2 = condition.operands.get(4).asByte(); + result = ((aniObj.x >= x1) && (aniObj.y >= y1) && (aniObj.x <= x2) && (aniObj.y <= y2)); + } + break; + + case 12: // controller + { + result = state.controllers[condition.operands.get(0).asByte()]; + } + break; + + case 13: // have.key + { + int key = state.vars[Defines.LAST_CHAR]; + if (key == 0) { + key = userInput.getKey(); + } + if (key > 0) { + state.vars[Defines.LAST_CHAR] = (key & 0xFF); + } + result = (key != 0); + } + break; + + case 14: // said + { + result = parser.said(condition.operands.get(0).asInts()); + } + break; + + case 15: // compare.strings + { + // Compare two strings. Ignore case, whitespace, and punctuation. + String str1 = state.strings[condition.operands.get(0).asByte()].toLowerCase().replaceAll("[ \t.,;:\'!-]", ""); + String str2 = state.strings[condition.operands.get(1).asByte()].toLowerCase().replaceAll("[ \t.,;:\'!-]", ""); + result = str1.equals(str2); + } + break; + + case 16: // obj.in.box + { + AnimatedObject aniObj = state.animatedObjects[condition.operands.get(0).asByte()]; + int x1 = condition.operands.get(1).asByte(); + int y1 = condition.operands.get(2).asByte(); + int x2 = condition.operands.get(3).asByte(); + int y2 = condition.operands.get(4).asByte(); + result = ((aniObj.x >= x1) && (aniObj.y >= y1) && ((aniObj.x + aniObj.xSize() - 1) <= x2) && (aniObj.y <= y2)); + } + break; + + case 17: // center.posn + { + AnimatedObject aniObj = state.animatedObjects[condition.operands.get(0).asByte()]; + int x1 = condition.operands.get(1).asByte(); + int y1 = condition.operands.get(2).asByte(); + int x2 = condition.operands.get(3).asByte(); + int y2 = condition.operands.get(4).asByte(); + result = ((aniObj.x + (aniObj.xSize() / 2) >= x1) && (aniObj.y >= y1) && (aniObj.x + (aniObj.xSize() / 2) <= x2) && (aniObj.y <= y2)); + } + break; + + case 18: // right.posn + { + AnimatedObject aniObj = state.animatedObjects[condition.operands.get(0).asByte()]; + int x1 = condition.operands.get(1).asByte(); + int y1 = condition.operands.get(2).asByte(); + int x2 = condition.operands.get(3).asByte(); + int y2 = condition.operands.get(4).asByte(); + result = (((aniObj.x + aniObj.xSize() - 1) >= x1) && (aniObj.y >= y1) && ((aniObj.x + aniObj.xSize() - 1) <= x2) && (aniObj.y <= y2)); + } + break; + + case 0xfc: // OR + { + result = false; + for (Condition orCondition : condition.operands.get(0).asConditions()) { + if (isConditionTrue(orCondition)) { + result = true; + break; + } + } + } + break; + + case 0xfd: // NOT + { + result = !isConditionTrue(condition.operands.get(0).asCondition()); + } + break; + } + + return result; + } + + /** + * Executes the given Action command. + * + * @param action The Action command to execute. + * + * @return The index of the next Action to execute, or 0 to rescan logics from top, or -1 when at end of Logic. + */ + private int executeAction(Action action) { + // Normally the next Action will be the next one in the Actions list, but this + // can be overwritten by the If and Goto actions. + int nextActionNum = action.logic.addressToActionIndex.get(action.address) + 1; + + switch (action.operation.opcode) { + case 0: // return + return -1; + + case 1: // increment + { + int varNum = action.operands.get(0).asByte(); + if (state.vars[varNum] < 255) state.vars[varNum]++; + } + break; + + case 2: // decrement + { + int varNum = action.operands.get(0).asByte(); + if (state.vars[varNum] > 0) state.vars[varNum]--; + } + break; + + case 3: // assignn + { + int varNum = action.operands.get(0).asByte(); + int value = action.operands.get(1).asByte(); + state.vars[varNum] = value; + } + break; + + case 4: // assignv + { + int varNum1 = action.operands.get(0).asByte(); + int varNum2 = action.operands.get(1).asByte(); + state.vars[varNum1] = state.vars[varNum2]; + } + break; + + case 5: // addn + { + int varNum = action.operands.get(0).asByte(); + int value = action.operands.get(1).asByte(); + state.vars[varNum] += value; + } + break; + + case 6: // addv + { + int varNum1 = action.operands.get(0).asByte(); + int varNum2 = action.operands.get(1).asByte(); + state.vars[varNum1] += state.vars[varNum2]; + } + break; + + case 7: // subn + { + int varNum = action.operands.get(0).asByte(); + int value = action.operands.get(1).asByte(); + state.vars[varNum] -= value; + } + break; + + case 8: // subv + { + int varNum1 = action.operands.get(0).asByte(); + int varNum2 = action.operands.get(1).asByte(); + state.vars[varNum1] -= state.vars[varNum2]; + } + break; + + case 9: // lindirectv + { + int varNum1 = action.operands.get(0).asByte(); + int varNum2 = action.operands.get(1).asByte(); + state.vars[state.vars[varNum1]] = state.vars[varNum2]; + } + break; + + case 10: // rindirect + { + int varNum1 = action.operands.get(0).asByte(); + int varNum2 = action.operands.get(1).asByte(); + state.vars[varNum1] = state.vars[state.vars[varNum2]]; + } + break; + + case 11: // lindirectn + { + int varNum = action.operands.get(0).asByte(); + int value = action.operands.get(1).asByte(); + state.vars[state.vars[varNum]] = value; + } + break; + + case 12: // set + { + state.flags[action.operands.get(0).asByte()] = true; + } + break; + + case 13: // reset + { + state.flags[action.operands.get(0).asByte()] = false; + } + break; + + case 14: // toggle + { + int flagNum = action.operands.get(0).asByte(); + state.flags[flagNum] = !state.flags[flagNum]; + } + break; + + case 15: // set.v + { + state.flags[state.vars[action.operands.get(0).asByte()]] = true; + } + break; + + case 16: // reset.v + { + state.flags[state.vars[action.operands.get(0).asByte()]] = false; + } + break; + + case 17: // toggle.v + { + int flagNum = state.vars[action.operands.get(0).asByte()]; + state.flags[flagNum] = !state.flags[flagNum]; + } + break; + + case 18: // new.room + newRoom(action.operands.get(0).asByte()); + return 0; + + case 19: // new.room.v + newRoom(state.vars[action.operands.get(0).asByte()]); + return 0; + + case 20: // load.logics + { + // All logics are already loaded in this interpreter, so nothing to do as such + // other than to remember it was "loaded". + Logic logic = state.logics[action.operands.get(0).asByte()]; + if ((logic != null) && !logic.isLoaded) { + logic.isLoaded = true; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.LOAD_LOGIC, logic.index); + } + } + break; + + case 21: // load.logics.v + { + // All logics are already loaded in this interpreter, so nothing to do as such + // other than to remember it was "loaded". + Logic logic = state.logics[state.vars[action.operands.get(0).asByte()]]; + if ((logic != null) && !logic.isLoaded) { + logic.isLoaded = true; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.LOAD_LOGIC, logic.index); + } + } + break; + + case 22: // call + { + if (executeLogic(action.operands.get(0).asByte())) { + // This means that a rescan from the top of Logic.0 should be done. + return 0; + } + } + break; + + case 23: // call.v + { + if (executeLogic(state.vars[action.operands.get(0).asByte()])) { + // This means that a rescan from the top of Logic.0 should be done. + return 0; + } + } + break; + + case 24: // load.pic + { + // All pictures are already loaded in this interpreter, so nothing to do as such + // other than to remember it was "loaded". + Picture pic = state.pictures[state.vars[action.operands.get(0).asByte()]]; + if ((pic != null) && !pic.isLoaded) { + pic.isLoaded = true; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.LOAD_PIC, pic.index); + } + } + break; + + case 25: // draw.pic + { + drawPicture(state.vars[action.operands.get(0).asByte()]); + } + break; + + case 26: // show.pic + { + showPicture(); + } + break; + + case 27: // discard.pic + { + // All pictures are kept loaded in this interpreter, so nothing to do as such + // other than to remember it was "unloaded". + Picture pic = state.pictures[state.vars[action.operands.get(0).asByte()]]; + if ((pic != null) && pic.isLoaded) { + pic.isLoaded = false; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.DISCARD_PIC, pic.index); + } + } + break; + + case 28: // overlay.pic + { + overlayPicture(state.vars[action.operands.get(0).asByte()]); + } + break; + + case 29: // show.pri.screen + { + showPriorityScreen(); + } + break; + + case 30: // load.view + { + // All views are already loaded in this interpreter, so nothing to do as such + // other than to remember it was "loaded". + View view = state.views[action.operands.get(0).asByte()]; + if ((view != null) && !view.isLoaded) { + view.isLoaded = true; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.LOAD_VIEW, view.index); + } + } + break; + + case 31: // load.view.v + { + // All views are already loaded in this interpreter, so nothing to do as such + // other than to remember it was "loaded". + View view = state.views[state.vars[action.operands.get(0).asByte()]]; + if ((view != null) && !view.isLoaded) { + view.isLoaded = true; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.LOAD_VIEW, view.index); + } + } + break; + + case 32: // discard.view + { + // All views are kept loaded in this interpreter, so nothing to do as such + // other than to remember it was "unloaded". + View view = state.views[action.operands.get(0).asByte()]; + if ((view != null) && view.isLoaded) { + view.isLoaded = false; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.DISCARD_VIEW, view.index); + } + } + break; + + case 33: // animate.obj + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.animate(); + } + break; + + case 34: // unanimate.all + { + state.restoreBackgrounds(); + for (AnimatedObject aniObj : state.animatedObjects) + { + aniObj.animated = false; + aniObj.drawn = false; + } + } + break; + + case 35: // draw + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + if (!aniObj.drawn) + { + aniObj.update = true; + aniObj.findPosition(); + aniObj.prevX = aniObj.x; + aniObj.prevY = aniObj.y; + aniObj.previousCel = aniObj.cel(); + state.restoreBackgrounds(state.updateObjectList); + aniObj.drawn = true; + state.drawObjects(state.makeUpdateObjectList()); + aniObj.show(pixels); + aniObj.noAdvance = false; + } + } + break; + + case 36: // erase + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.restoreBackgrounds(state.updateObjectList); + if (!aniObj.update) + { + state.restoreBackgrounds(state.stoppedObjectList); + } + aniObj.drawn = false; + if (!aniObj.update) + { + state.drawObjects(state.makeStoppedObjectList()); + } + state.drawObjects(state.makeUpdateObjectList()); + aniObj.show(pixels); + } + break; + + case 37: // position + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.x = aniObj.prevX = (short)action.operands.get(1).asByte(); + aniObj.y = aniObj.prevY = (short)action.operands.get(2).asByte(); + } + break; + + case 38: // position.v + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.x = aniObj.prevX = (short)state.vars[action.operands.get(1).asByte()]; + aniObj.y = aniObj.prevY = (short)state.vars[action.operands.get(2).asByte()]; + } + break; + + case 39: // get.posn + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = aniObj.x; + state.vars[action.operands.get(2).asByte()] = aniObj.y; + } + break; + + case 40: // reposition + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.reposition(state.vars[action.operands.get(1).asByte()], state.vars[action.operands.get(2).asByte()]); + } + break; + + case 41: // set.view + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.setView(action.operands.get(1).asByte()); + } + break; + + case 42: // set.view.v + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.setView(state.vars[action.operands.get(1).asByte()]); + } + break; + + case 43: // set.loop + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.setLoop(action.operands.get(1).asByte()); + } + break; + + case 44: // set.loop.v + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.setLoop(state.vars[action.operands.get(1).asByte()]); + } + break; + + case 45: // fix.loop + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.fixedLoop = true; + } + break; + + case 46: // release.loop + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.fixedLoop = false; + } + break; + + case 47: // set.cel + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.setCel(action.operands.get(1).asByte()); + aniObj.noAdvance = false; + } + break; + + case 48: // set.cel.v + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.setCel(state.vars[action.operands.get(1).asByte()]); + aniObj.noAdvance = false; + } + break; + + case 49: // last.cel + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = (aniObj.numberOfCels() - 1); + } + break; + + case 50: // current.cel + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = aniObj.currentCel; + } + break; + + case 51: // current.loop + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = aniObj.currentLoop; + } + break; + + case 52: // current.view + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = aniObj.currentView; + } + break; + + case 53: // number.of.loops + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = aniObj.numberOfLoops(); + } + break; + + case 54: // set.priority + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.fixedPriority = true; + aniObj.priority = (byte)action.operands.get(1).asByte(); + } + break; + + case 55: // set.priority.v + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.fixedPriority = true; + aniObj.priority = (byte)state.vars[action.operands.get(1).asByte()]; + } + break; + + case 56: // release.priority + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.fixedPriority = false; + } + break; + + case 57: // get.priority + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = aniObj.priority; + } + break; + + case 58: // stop.update + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + if (aniObj.update) + { + state.restoreBackgrounds(); + aniObj.update = false; + state.drawObjects(); + } + } + break; + + case 59: // start.update + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + if (!aniObj.update) + { + state.restoreBackgrounds(); + aniObj.update = true; + state.drawObjects(); + } + } + break; + + case 60: // force.update + { + // Although this command has a parameter, it seems to get ignored. Instead + // every AnimatedObject is redrawn and blitted to the screen. + state.restoreBackgrounds(); + state.drawObjects(); + state.showObjects(pixels); + } + break; + + case 61: // ignore.horizon + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.ignoreHorizon = true; + } + break; + + case 62: // observe.horizon + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.ignoreHorizon = false; + } + break; + + case 63: // set.horizon + { + state.horizon = action.operands.get(0).asByte(); + } + break; + + case 64: // object.on.water + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.stayOnWater = true; + } + break; + + case 65: // object.on.land + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.stayOnLand = true; + } + break; + + case 66: // object.on.anything + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.stayOnLand = false; + aniObj.stayOnWater = false; + } + break; + + case 67: // ignore.objs + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.ignoreObjects = true; + } + break; + + case 68: // observe.objs + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.ignoreObjects = false; + } + break; + + case 69: // distance + { + AnimatedObject aniObj1 = state.animatedObjects[action.operands.get(0).asByte()]; + AnimatedObject aniObj2 = state.animatedObjects[action.operands.get(1).asByte()]; + state.vars[action.operands.get(2).asByte()] = aniObj1.distance(aniObj2); + } + break; + + case 70: // stop.cycling + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.cycle = false; + } + break; + + case 71: // start.cycling + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.cycle = true; + } + break; + + case 72: // normal.cycle + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.cycleType = CycleType.NORMAL; + aniObj.cycle = true; + } + break; + + case 73: // end.of.loop + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + int flagNum = action.operands.get(1).asByte(); + aniObj.cycleType = CycleType.END_LOOP; + aniObj.update = true; + aniObj.cycle = true; + aniObj.noAdvance = true; + aniObj.motionParam1 = (short)flagNum; + state.flags[flagNum] = false; + } + break; + + case 74: // reverse.cycle + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.cycleType = CycleType.REVERSE; + aniObj.cycle = true; + } + break; + + case 75: // reverse.loop + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + int flagNum = action.operands.get(1).asByte(); + aniObj.cycleType = CycleType.REVERSE_LOOP; + aniObj.update = true; + aniObj.cycle = true; + aniObj.noAdvance = true; + aniObj.motionParam1 = (short)flagNum; + state.flags[flagNum] = false; + } + break; + + case 76: // cycle.time + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.cycleTimeCount = aniObj.cycleTime = state.vars[action.operands.get(1).asByte()]; + } + break; + + case 77: // stop.motion + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.direction = 0; + aniObj.motionType = MotionType.NORMAL; + if (aniObj == state.ego) + { + state.vars[Defines.EGODIR] = 0; + state.userControl = false; + } + } + break; + + case 78: // start.motion + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.motionType = MotionType.NORMAL; + if (aniObj == state.ego) + { + state.vars[Defines.EGODIR] = 0; + state.userControl = true; + } + } + break; + + case 79: // step.size + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.stepSize = state.vars[action.operands.get(1).asByte()]; + } + break; + + case 80: // step.time + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.stepTimeCount = aniObj.stepTime = state.vars[action.operands.get(1).asByte()]; + } + break; + + case 81: // move.obj + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.startMoveObj( + action.operands.get(1).asByte(), action.operands.get(2).asByte(), + action.operands.get(3).asByte(), action.operands.get(4).asByte()); + } + break; + + case 82: // move.obj.v + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.startMoveObj( + state.vars[action.operands.get(1).asByte()], state.vars[action.operands.get(2).asByte()], + state.vars[action.operands.get(3).asByte()], action.operands.get(4).asByte()); + } + break; + + case 83: // follow.ego + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.startFollowEgo(action.operands.get(1).asByte(), action.operands.get(2).asByte()); + } + break; + + case 84: // wander + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.startWander(); + } + break; + + case 85: // normal.motion + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.motionType = MotionType.NORMAL; + } + break; + + case 86: // set.dir + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.direction = (byte)state.vars[action.operands.get(1).asByte()]; + } + break; + + case 87: // get.dir + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + state.vars[action.operands.get(1).asByte()] = aniObj.direction; + } + break; + + case 88: // ignore.blocks + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.ignoreBlocks = true; + } + break; + + case 89: // observe.blocks + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.ignoreBlocks = false; + } + break; + + case 90: // block + { + state.blocking = true; + state.blockUpperLeftX = (short)action.operands.get(0).asByte(); + state.blockUpperLeftY = (short)action.operands.get(1).asByte(); + state.blockLowerRightX = (short)action.operands.get(2).asByte(); + state.blockLowerRightY = (short)action.operands.get(3).asByte(); + } + break; + + case 91: // unblock + { + state.blocking = false; + } + break; + + case 92: // get + { + state.objects.objects.get(action.operands.get(0).asByte()).room = Defines.CARRYING; + } + break; + + case 93: // get.v + { + state.objects.objects.get(state.vars[action.operands.get(0).asByte()]).room = Defines.CARRYING; + } + break; + + case 94: // drop + { + state.objects.objects.get(action.operands.get(0).asByte()).room = Defines.LIMBO; + } + break; + + case 95: // put + { + state.objects.objects.get(action.operands.get(0).asByte()).room = state.vars[action.operands.get(1).asByte()]; + } + break; + + case 96: // put.v + { + state.objects.objects.get(state.vars[action.operands.get(0).asByte()]).room = state.vars[action.operands.get(1).asByte()]; + } + break; + + case 97: // get.room.v + { + state.vars[action.operands.get(1).asByte()] = state.objects.objects.get(state.vars[action.operands.get(0).asByte()]).room; + } + break; + + case 98: // load.sound + { + // All sounds are already loaded in this interpreter, so nothing to do as such + // other than to remember it was "loaded". + int soundNum = action.operands.get(0).asByte(); + Sound sound = state.sounds[soundNum]; + if ((sound != null) && !sound.isLoaded) + { + soundPlayer.loadSound(sound); + sound.isLoaded = true; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.LOAD_SOUND, sound.index); + } + } + break; + + case 99: // sound + { + int soundNum = action.operands.get(0).asByte(); + int endFlag = action.operands.get(1).asByte(); + state.flags[endFlag] = false; + Sound sound = state.sounds[soundNum]; + if ((sound != null) && (sound.isLoaded)) + { + this.soundPlayer.playSound(sound, endFlag); + } + } + break; + + case 100: // stop.sound + { + this.soundPlayer.stopSound(); + } + break; + + case 101: // print + { + this.textGraphics.print(action.logic.messages.get(action.operands.get(0).asByte())); + } + break; + + case 102: // print.v + { + this.textGraphics.print(action.logic.messages.get(state.vars[action.operands.get(0).asByte()])); + } + break; + + case 103: // display + { + int row = action.operands.get(0).asByte(); + int col = action.operands.get(1).asByte(); + String message = action.logic.messages.get(action.operands.get(2).asByte()); + this.textGraphics.display(message, row, col); + } + break; + + case 104: // display.v + { + int row = state.vars[action.operands.get(0).asByte()]; + int col = state.vars[action.operands.get(1).asByte()]; + String message = action.logic.messages.get(state.vars[action.operands.get(2).asByte()]); + this.textGraphics.display(message, row, col); + } + break; + + case 105: // clear.lines + { + int colour = textGraphics.makeBackgroundColour(action.operands.get(2).asByte()); + textGraphics.clearLines(action.operands.get(0).asByte(), action.operands.get(1).asByte(), colour); + } + break; + + case 106: // text.screen + { + textGraphics.textScreen(); + } + break; + + case 107: // graphics + { + textGraphics.graphicsScreen(); + } + break; + + case 108: // set.cursor.char + { + String cursorStr = action.logic.messages.get(action.operands.get(0).asByte()); + state.cursorCharacter = (cursorStr.length() > 0? cursorStr.charAt(0) : (char)0); + } + break; + + case 109: // set.text.attribute + { + textGraphics.setTextAttribute(action.operands.get(0).asByte(), action.operands.get(1).asByte()); + } + break; + + case 110: // shake.screen + { + shakeScreen(action.operands.get(0).asByte()); + } + break; + + case 111: // configure.screen + { + state.pictureRow = action.operands.get(0).asByte(); + state.inputLineRow = action.operands.get(1).asByte(); + state.statusLineRow = action.operands.get(2).asByte(); + } + break; + + case 112: // status.line.on + { + state.showStatusLine = true; + textGraphics.clearLines(state.statusLineRow, state.statusLineRow, 15); + textGraphics.updateStatusLine(); + } + break; + + case 113: // status.line.off + { + state.showStatusLine = false; + textGraphics.clearLines(state.statusLineRow, state.statusLineRow, 0); + } + break; + + case 114: // set.string + { + state.strings[action.operands.get(0).asByte()] = action.logic.messages.get(action.operands.get(1).asByte()); + } + break; + + case 115: // get.string + { + textGraphics.getString(action.operands.get(0).asByte(), action.logic.messages.get(action.operands.get(1).asByte()), + action.operands.get(2).asByte(), action.operands.get(3).asByte(), action.operands.get(4).asByte()); + } + break; + + case 116: // word.to.string + { + state.strings[action.operands.get(0).asByte()] = state.recognisedWords.get(action.operands.get(1).asByte()); + } + break; + + case 117: // parse + { + parser.parseString(action.operands.get(0).asByte()); + } + break; + + case 118: // get.num + { + state.vars[action.operands.get(1).asByte()] = textGraphics.getNum(action.logic.messages.get(action.operands.get(0).asByte())); + } + break; + + case 119: // prevent.input + { + state.acceptInput = false; + textGraphics.updateInputLine(); + } + break; + + case 120: // accept.input + { + state.acceptInput = true; + textGraphics.updateInputLine(); + } + break; + + case 121: // set.key + { + int keyCode = (action.operands.get(0).asByte() + (action.operands.get(1).asByte() << 8)); + if (userInput.keyCodeMap.containsKey(keyCode)) + { + int controllerNum = action.operands.get(2).asByte(); + int interKeyCode = userInput.keyCodeMap.get(keyCode); + if (state.keyToControllerMap.containsKey(interKeyCode)) + { + state.keyToControllerMap.remove(interKeyCode); + } + state.keyToControllerMap.put(userInput.keyCodeMap.get(keyCode), controllerNum); + } + } + break; + + case 122: // add.to.pic + { + AnimatedObject picObj = new AnimatedObject(state, -1); + picObj.addToPicture( + action.operands.get(0).asByte(), action.operands.get(1).asByte(), action.operands.get(2).asByte(), + action.operands.get(3).asByte(), action.operands.get(4).asByte(), action.operands.get(5).asByte(), + action.operands.get(6).asByte(), pixels); + splitPriorityPixels(); + picObj.show(pixels); + } + break; + + case 123: // add.to.pic.v + { + AnimatedObject picObj = new AnimatedObject(state, -1); + picObj.addToPicture( + state.vars[action.operands.get(0).asByte()], state.vars[action.operands.get(1).asByte()], + state.vars[action.operands.get(2).asByte()], state.vars[action.operands.get(3).asByte()], + state.vars[action.operands.get(4).asByte()], state.vars[action.operands.get(5).asByte()], + state.vars[action.operands.get(6).asByte()], pixels); + splitPriorityPixels(); + } + break; + + case 124: // status + { + inventory.showInventoryScreen(); + } + break; + + case 125: // save.game + { + savedGames.saveGameState(); + } + break; + + case 126: // restore.game + { + if (savedGames.restoreGameState()) + { + soundPlayer.reset(); + menu.enableAllMenus(); + replayScriptEvents(); + showPicture(false); + textGraphics.updateStatusLine(); + return 0; + } + } + break; + + case 127: // init.disk + { + // No need to implement this. + } + break; + + case 128: // restart.game + { + if (state.flags[Defines.NO_PRMPT_RSTRT] || textGraphics.windowPrint("Press ENTER to restart\nthe game.\n\nPress ESC to continue\nthis game.")) + { + soundPlayer.reset(); + state.init(); + state.flags[Defines.RESTART] = true; + menu.enableAllMenus(); + textGraphics.clearLines(0, 24, 0); + return 0; + } + } + break; + + case 129: // show.obj + { + inventory.showInventoryObject(action.operands.get(0).asByte()); + } + break; + + case 130: // random.num + { + int minVal = action.operands.get(0).asByte(); + int maxVal = action.operands.get(1).asByte(); + state.vars[action.operands.get(2).asByte()] = (((state.random.nextInt(255) % (maxVal - minVal + 1)) + minVal) & 0xFF); + } + break; + + case 131: // program.control + { + state.userControl = false; + } + break; + + case 132: // player.control + { + state.userControl = true; + state.ego.motionType = MotionType.NORMAL; + } + break; + + case 133: // obj.status.v + { + AnimatedObject aniObj = state.animatedObjects[state.vars[action.operands.get(0).asByte()]]; + textGraphics.windowPrint(aniObj.getStatusStr()); + } + break; + + case 134: // quit + { + int quitAction = (action.operands.size() == 0 ? 1 : action.operands.get(0).asByte()); + if ((quitAction == 1) || textGraphics.windowPrint("Press ENTER to quit.\nPress ESC to keep playing.")) + { + soundPlayer.shutdown(); + QuitAction.exit(); + } + } + break; + + case 135: // show.mem + { + // No need to implement this. + } + break; + + case 136: // pause + { + // Note: In the original AGI interpreter, pause stopped sound rather than pause + soundPlayer.stopSound(); + this.textGraphics.print(" Game paused.\nPress Enter to continue."); + } + break; + + case 137: // echo.line + { + if (state.currentInput.length() < state.lastInput.length()) + { + state.currentInput.append(state.lastInput.substring(state.currentInput.length())); + } + } + break; + + case 138: // cancel.line + { + state.currentInput.setLength(0); + } + break; + + case 139: // init.joy + { + // No need to implement this. + } + break; + + case 140: // toggle.monitor + { + // No need to implement this. + } + break; + + case 141: // version + { + this.textGraphics.print("Adventure Game Interpreter\n Version " + state.version); + } + break; + + case 142: // script.size + { + state.scriptBuffer.setScriptSize(action.operands.get(0).asByte()); + } + break; + + // -------------------------------------------------------------------------------------------------- + // ---- AGI version 2.001 in effect ended here. It did have a 143 and 144 but they were different --- + // -------------------------------------------------------------------------------------------------- + + case 143: // set.game.id (was max.drawn in AGI v2.001) + { + state.gameId = action.logic.messages.get(action.operands.get(0).asByte()); + } + break; + + case 144: // log + { + // No need to implement this. + } + break; + + case 145: // set.scan.start + { + state.scanStart[action.logic.index] = action.getActionNumber() + 1; + } + break; + + case 146: // reset.scan.start + { + state.scanStart[action.logic.index] = 0; + } + break; + + case 147: // reposition.to + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.x = (short)action.operands.get(1).asByte(); + aniObj.y = (short)action.operands.get(2).asByte(); + aniObj.repositioned = true; + aniObj.findPosition(); // Make sure that this position is OK. + } + break; + + case 148: // reposition.to.v + { + AnimatedObject aniObj = state.animatedObjects[action.operands.get(0).asByte()]; + aniObj.x = (short)state.vars[action.operands.get(1).asByte()]; + aniObj.y = (short)state.vars[action.operands.get(2).asByte()]; + aniObj.repositioned = true; + aniObj.findPosition(); // Make sure that this position is OK. + } + break; + + case 149: // trace.on + { + // No need to implement this. + } + break; + + case 150: // trace.info + { + // No need to implement this. + } + break; + + case 151: // print.at + { + String message = action.logic.messages.get(action.operands.get(0).asByte()); + int row = action.operands.get(1).asByte(); + int col = action.operands.get(2).asByte(); + int width = action.operands.get(3).asByte(); + this.textGraphics.printAt(message, row, col, width); + } + break; + + case 152: // print.at.v + { + String message = action.logic.messages.get(state.vars[action.operands.get(0).asByte()]); + int row = action.operands.get(1).asByte(); + int col = action.operands.get(2).asByte(); + int width = action.operands.get(3).asByte(); + this.textGraphics.printAt(message, row, col, width); + } + break; + + case 153: // discard.view.v + { + // All views are kept loaded in this interpreter, so nothing to do as such + // other than to remember it was "unloaded". + View view = state.views[state.vars[action.operands.get(0).asByte()]]; + if ((view != null) && view.isLoaded) + { + view.isLoaded = false; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.DISCARD_VIEW, view.index); + } + } + break; + + case 154: // clear.text.rect + { + int top = action.operands.get(0).asByte(); + int left = action.operands.get(1).asByte(); + int bottom = action.operands.get(2).asByte(); + int right = action.operands.get(3).asByte(); + int colour = textGraphics.makeBackgroundColour(action.operands.get(4).asByte()); + textGraphics.clearRect(top, left, bottom, right, colour); + } + break; + + case 155: // set.upper.left + { + // Only used on the Apple. No need to implement. + } + break; + + // -------------------------------------------------------------------------------------------------- + // ---- AGI version 2.089 ends with command 155 above, i.e before the menu system was introduced ---- + // -------------------------------------------------------------------------------------------------- + + case 156: // set.menu + { + menu.setMenu(action.logic.messages.get(action.operands.get(0).asByte())); + } + break; + + case 157: // set.menu.item + { + String menuItemName = action.logic.messages.get(action.operands.get(0).asByte()); + byte controllerNum = (byte)action.operands.get(1).asByte(); + menu.setMenuItem(menuItemName, controllerNum); + } + break; + + case 158: // submit.menu + { + menu.submitMenu(); + } + break; + + case 159: // enable.item + { + menu.enableItem(action.operands.get(0).asByte()); + } + break; + + case 160: // disable.item + { + menu.disableItem(action.operands.get(0).asByte()); + } + break; + + case 161: // menu.input + { + state.menuOpen = true; + } + break; + + // ------------------------------------------------------------------------------------------------- + // ---- AGI version 2.272 ends with command 161 above, i.e after the menu system was introduced ---- + // ------------------------------------------------------------------------------------------------- + + case 162: // show.obj.v + { + inventory.showInventoryObject(state.vars[action.operands.get(0).asByte()]); + } + break; + + case 163: // open.dialogue + { + // Appears to be something specific to monochrome. No need to implement. + } + break; + + case 164: // close.dialogue + { + // Appears to be something specific to monochrome. No need to implement. + } + break; + + case 165: // mul.n + { + int varNum = action.operands.get(0).asByte(); + int value = action.operands.get(1).asByte(); + state.vars[varNum] *= value; + } + break; + + case 166: // mul.v + { + int varNum1 = action.operands.get(0).asByte(); + int varNum2 = action.operands.get(1).asByte(); + state.vars[varNum1] *= state.vars[varNum2]; + } + break; + + case 167: // div.n + { + int varNum = action.operands.get(0).asByte(); + int value = action.operands.get(1).asByte(); + state.vars[varNum] /= value; + } + break; + + case 168: // div.v + { + int varNum1 = action.operands.get(0).asByte(); + int varNum2 = action.operands.get(1).asByte(); + state.vars[varNum1] /= state.vars[varNum2]; + } + break; + + case 169: // close.window + { + textGraphics.closeWindow(); + } + break; + + case 170: // set.simple (i.e. simpleName variable for saved games) + { + state.simpleName = action.logic.messages.get(action.operands.get(0).asByte()); + } + break; + + case 171: // push.script + { + state.scriptBuffer.pushScript(); + } + break; + + case 172: // pop.script + { + state.scriptBuffer.popScript(); + } + break; + + case 173: // hold.key + { + state.holdKey = true; + } + break; + + // -------------------------------------------------------------------------------------------------- + // ---- AGI version 2.915/2.917 ends with command 173 above ---- + // -------------------------------------------------------------------------------------------------- + + case 174: // set.pri.base + { + state.priorityBase = action.operands.get(0).asByte(); + } + break; + + case 175: // discard.sound + { + // Note: Interpreter 2.936 doesn't persist discard sound to the script event buffer. + } + break; + + // -------------------------------------------------------------------------------------------------- + // ---- AGI version 2.936 ends with command 175 above ---- + // -------------------------------------------------------------------------------------------------- + + case 176: // hide.mouse + { + // This command isn't supported by PC versions of original AGI Interpreter. + } + break; + + case 177: // allow.menu + { + state.menuEnabled = (action.operands.get(0).asByte() != 0); + } + break; + + case 178: // show.mouse + { + // This command isn't supported by PC versions of original AGI Interpreter. + } + break; + + case 179: // fence.mouse + { + // This command isn't supported by PC versions of original AGI Interpreter. + } + break; + + case 180: // mouse.posn + { + // This command isn't supported by PC versions of original AGI Interpreter. + } + break; + + case 181: // release.key + { + state.holdKey = false; + } + break; + + case 182: // adj.ego.move.to.x.y + { + // This command isn't supported by PC versions of original AGI Interpreter. + } + break; + + case 0xfe: // Unconditional branch: else, goto. + { + nextActionNum = ((GotoAction)action).getDestinationActionIndex(); + } + break; + + case 0xff: // Conditional branch: if. + { + for (Condition condition : action.operands.get(0).asConditions()) { + if (!isConditionTrue(condition)) { + nextActionNum = ((IfAction)action).getDestinationActionIndex(); + break; + } + } + } + break; + + default: // Error has occurred + break; + } + + return nextActionNum; + } + + /** + * Executes the Logic identified by the given logic number. + * + * @param logicNum The number of the Logic to execute. + * + * @return true if logics should be rescanned from the top (i.e. top of Logic 0); otherwise false. + */ + public boolean executeLogic(int logicNum) { + // Remember the previous Logic number. + int previousLogNum = state.currentLogNum; + + // Store the new Logic number in the state so that actions will know this. + state.currentLogNum = logicNum; + + // Prepare to start executing the Logic. + Logic logic = state.logics[logicNum]; + int actionNum = state.scanStart[logicNum]; + + // Continually execute the Actions in the Logic until one of them tells us to exit. + do actionNum = executeAction(logic.actions.get(actionNum)); while (actionNum > 0); + + // Restore the previous Logic number before we leave. + state.currentLogNum = previousLogNum; + + // If ExecuteAction return 0, then it means that a newroom, restore or restart is + // happening. In those cases, we need to immediately rescan logics from the top of Logic.0 + return (actionNum == 0); + } + + /** + * Performs all the necessary updates to vars, flags, animated objects, controllers, + * and other state to prepare for entry in to the next room. + * + * @param roomNum + */ + private void newRoom(int roomNum) { + // Simulate a slow room change if there is a text window open. + if (textGraphics.isWindowOpen()) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + // Ignore + } + } + + // Turn off sound. + soundPlayer.reset(); + + // Clear the script event buffer ready for next room. + state.scriptBuffer.initScript(); + state.scriptBuffer.scriptOn(); + + // Resets the Logics, Views, Pictures and Sounds back to new room state. + state.resetResources(); + + // Carry over ego's view number. + // TODO: For some reason in MH2, the ego View can be null at this point. Needs investigation to determine why. + if (state.ego.view() != null) { + state.vars[Defines.CURRENT_EGO] = (state.ego.view().index & 0xFF); + } + + // Reset state for all animated objects. + for (AnimatedObject aniObj : state.animatedObjects) aniObj.reset(); + + // Current room logic is loaded automatically on room change and not directly by load.logic + Logic logic = state.logics[roomNum]; + logic.isLoaded = true; + state.scriptBuffer.addScript(ScriptBuffer.ScriptBufferEventType.LOAD_LOGIC, logic.index); + + // If ego collided with a border, set his position in the new room to + // the appropriate edge of the screen. + switch (state.vars[Defines.EGOEDGE]) { + case Defines.TOP: + state.ego.y = Defines.MAXY; + break; + + case Defines.RIGHT: + state.ego.x = Defines.MINX; + break; + + case Defines.BOTTOM: + state.ego.y = Defines.HORIZON + 1; + break; + + case Defines.LEFT: + state.ego.x = (short)(Defines.MAXX + 1 - state.ego.xSize()); + break; + } + + // Change the room number. + state.vars[Defines.PREVROOM] = state.vars[Defines.CURROOM]; + state.vars[Defines.CURROOM] = roomNum; + + // Set flags and vars as appropriate for a new room. + state.vars[Defines.OBJHIT] = 0; + state.vars[Defines.OBJEDGE] = 0; + state.vars[Defines.UNKNOWN_WORD] = 0; + state.vars[Defines.EGOEDGE] = 0; + state.flags[Defines.INPUT] = false; + state.flags[Defines.INITLOGS] = true; + state.userControl = true; + state.blocking = false; + state.horizon = Defines.HORIZON; + state.clearControllers(); + + // Draw the status line, if applicable. + textGraphics.updateStatusLine(); + } +} diff --git a/core/src/main/java/com/agifans/agile/Defines.java b/core/src/main/java/com/agifans/agile/Defines.java new file mode 100644 index 0000000..0b1cc22 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Defines.java @@ -0,0 +1,175 @@ +package com.agifans.agile; + +/** + * The core constants and definitions within the AGI system. + */ +public class Defines { + + /* ------------------------ System variables -------------------------- */ + + public final static int CURROOM = 0; /* current.room */ + + public final static int PREVROOM = 1; /* previous.room */ + + public final static int EGOEDGE = 2; /* edge.ego.hit */ + + public final static int SCORE = 3; /* score */ + + public final static int OBJHIT = 4; /* obj.hit.edge */ + + public final static int OBJEDGE = 5; /* edge.obj.hit */ + + public final static int EGODIR = 6; /* ego's direction */ + + public final static int MAXSCORE = 7; /* maximum possible score */ + + public final static int MEMLEFT = 8; /* remaining heap space in pages */ + + public final static int UNKNOWN_WORD = 9; /* number of unknown word */ + + public final static int ANIMATION_INT = 10; /* animation interval */ + + public final static int SECONDS = 11; + + public final static int MINUTES = 12; /* time since game start */ + + public final static int HOURS = 13; + + public final static int DAYS = 14; + + public final static int DBL_CLK_DELAY = 15; + + public final static int CURRENT_EGO = 16; + + public final static int ERROR_NUM = 17; + + public final static int ERROR_PARAM = 18; + + public final static int LAST_CHAR = 19; + + public final static int MACHINE_TYPE = 20; + + public final static int PRINT_TIMEOUT = 21; + + public final static int NUM_VOICES = 22; + + public final static int ATTENUATION = 23; + + public final static int INPUTLEN = 24; + + public final static int SELECTED_OBJ = 25; /* selected object number */ + + public final static int MONITOR_TYPE = 26; + + + /* ------------------------ System flags ------------------------ */ + + public final static int ONWATER = 0; /* on.water */ + + public final static int SEE_EGO = 1; /* can we see ego? */ + + public final static int INPUT = 2; /* have.input */ + + public final static int HITSPEC = 3; /* hit.special */ + + public final static int HADMATCH = 4; /* had a word match */ + + public final static int INITLOGS = 5; /* signal to init logics */ + + public final static int RESTART = 6; /* is a restart in progress? */ + + public final static int NO_SCRIPT = 7; /* don't add to the script buffer */ + + public final static int DBL_CLK = 8; /* enable double click on joystick */ + + public final static int SOUNDON = 9; /* state of sound playing */ + + public final static int TRACE_ENABLE = 10; /* to enable tracing */ + + public final static int HAS_NOISE = 11; /* does machine have noise channel */ + + public final static int RESTORE = 12; /* restore game in progress */ + + public final static int ENABLE_SELECT = 13; /* allow selection of objects from inventory screen */ + + public final static int ENABLE_MENU = 14; + + public final static int LEAVE_WIN = 15; /* leave windows on the screen */ + + public final static int NO_PRMPT_RSTRT = 16; /* don't prompt on restart */ + + + /* ------------------------ Miscellaneous ------------------------ */ + + public final static int NUMVARS = 256; /* number of vars */ + + public final static int NUMFLAGS = 256; /* number of flags */ + + public final static int NUMCONTROL = 50; /* number of controllers */ + + public final static int NUMWORDS = 10; /* maximum # of words recognized in input */ + + public final static int NUMANIMATED = 256; /* maximum # of animated objects */ + + public final static int MAXVAR = 255; /* maximum value for a var */ + + public final static int TEXTCOLS = 40; /* number of columns of text */ + + public final static int TEXTLINES = 25; /* number of lines of text */ + + public final static int MAXINPUT = 40; /* maximum length of user input */ + + public final static int DIALOGUE_WIDTH = 35; /* maximum width of dialog box */ + + public final static int NUMSTRINGS = 24; /* number of user-definable strings */ + + public final static int STRLENGTH = 40; /* maximum length of user strings */ + + public final static int GLSIZE = 40; /* maximum length for GetLine calls, used internally for things like save dialog */ + + public final static int PROMPTSTR = 0; /* string number of prompt */ + + public final static int ID_LEN = 7; /* length of gameID string */ + + public final static int MAXDIST = 50; /* maximum movement distance */ + + public final static int MINDIST = 6; /* minimum movement distance */ + + public final static int BACK_MOST_PRIORITY = 4; /* priority value of back most priority */ + + + /* ------------------------ Inventory item final staticants --------------------------- */ + + public final static int LIMBO = 0; /* room number of objects that are gone */ + + public final static int CARRYING = 255; /* room number of objects in ego's posession */ + + + /* ------------------------ Default status and input row numbers ------------------------ */ + + public final static int STATUSROW = 21; + + public final static int INPUTROW = 23; + + + /* ------------------------ Screen edges ------------------------ */ + + public final static int TOP = 1; + + public final static int RIGHT = 2; + + public final static int BOTTOM = 3; + + public final static int LEFT = 4; + + public final static int MINX = 0; + + public final static int MINY = 0; + + public final static int MAXX = 159; + + public final static int MAXY = 167; + + public final static int HORIZON = 36; + +} diff --git a/core/src/main/java/com/agifans/agile/Detection.java b/core/src/main/java/com/agifans/agile/Detection.java new file mode 100644 index 0000000..06d29e3 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Detection.java @@ -0,0 +1,323 @@ +package com.agifans.agile; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.math.BigInteger; +import java.security.MessageDigest; + +import com.agifans.agile.agilib.Game; + +/** + * The Detection class handles detection of AGI games, demos and fan made games. + */ +public class Detection { + + /** + * The short ID of the game, as known by the AGILE interpreter. For fan made games, it is always "fanmade". + */ + public String gameId = "unknown"; + + /** + * The displayable name of the game. + */ + public String gameName = "Unrecognised game"; + + /** + * Constructor for Detection. + * + * @param game + */ + public Detection(Game game) { + try { + StringBuilder dirFilePath = new StringBuilder(); + dirFilePath.append(game.gameFolder); + dirFilePath.append(File.separator); + if (game.v3GameSig != null) { + dirFilePath.append(game.v3GameSig.toUpperCase()); + dirFilePath.append("DIR"); + } + else { + dirFilePath.append("LOGDIR"); + } + + // Calculate MD5 hash of the game. + byte[] data = readBytesFromFile(dirFilePath.toString()); + byte[] hash = MessageDigest.getInstance("MD5").digest(data); + String md5HashString = new BigInteger(1, hash).toString(16); + + // Compare with known MD5 hash values for AGI games and demos. + for (int i = 0; i < gameDefinitions.length; i++) { + if (gameDefinitions[i][2].equals(md5HashString)) { + gameId = gameDefinitions[i][0]; + gameName = gameDefinitions[i][1]; + break; + } + } + } + catch (Exception e) { + // Failure in game detection code. Continue with the default unrecognised game values. + e.printStackTrace(); + } + } + + private byte[] readBytesFromFile(String filePath) throws FileNotFoundException, IOException { + try (FileInputStream fis = new FileInputStream(filePath)) { + int numOfBytesReads; + byte[] data = new byte[256]; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + while ((numOfBytesReads = fis.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, numOfBytesReads); + } + return buffer.toByteArray(); + } + } + + /** + * MD5 hash values for known games and demos. + */ + private static String[][] gameDefinitions = { + {"agidemo", "AGI Demo 1 (1987-05-20)", "9c4a5b09cc3564bc48b4766e679ea332"}, + {"agidemo", "AGI Demo 2 (1987-11-24 3.5\")", "e8ebeb0bbe978172fe166f91f51598c7"}, + {"agidemo", "AGI Demo 2 (1987-11-24 [version 1] 5.25\")", "852ac303a374df62571642ca1e2d1f0a"}, + {"agidemo", "AGI Demo 2 (1987-11-25 [version 2] 5.25\")", "1503f02086ea9f388e7e041c039eaa69"}, + {"agidemo", "AGI Demo 2 (Tandy)", "94eca021fe7da8f8572c2edcc631bbc6"}, + {"agidemo", "AGI Demo Kings Quest III and Space Quest I", "502e6bf96827b6c4d3e67c9cdccd1033"}, + {"bc", "The Black Cauldron (2.00 1987-06-14)", "7f598d4712319b09d7bd5b3be10a2e4a"}, + {"ddp", "Donald Duck's Playground (1.0A 1986-08-08)", "64388812e25dbd75f7af1103bc348596"}, + {"ddp", "Donald Duck's Playground (1.0C 1986-06-09)", "550971d196f65190a5c760d2479406ef"}, + {"ddp", "Donald Duck's Playground (1.50 1987-06-22)", "268074cc8cb75aa2227c4398886d7acd"}, + {"kq1", "King's Quest I (2.0F 1987-05-05 5.25\"/3.5\")", "10ad66e2ecbd66951534a50aedcd0128"}, + {"kq2", "King's Quest II (2.1 1987-04-10)", "759e39f891a0e1d86dd29d7de485c6ac"}, + {"kq2", "King's Quest II (2.2 1987-05-07 5.25\"/3.5\")", "b944c4ff18fb8867362dc21cc688a283"}, + {"kq3", "King's Quest III (1.01 1986-11-08)", "9c2b34e7ffaa89c8e2ecfeb3695d444b"}, + {"kq3", "King's Quest III (2.00 1987-05-25 5.25\")", "18aad8f7acaaff760720c5c6885b6bab"}, + {"kq3", "King's Quest III (2.00 1987-05-25 5.25\")", "b46dc63d6272fb6ed24a004ad580a033"}, + {"kq3", "King's Quest III (2.14 1988-03-15 3.5\")", "d3d17b77b3b3cd13246749231d9473cd"}, + {"lsl1", "Leisure Suit Larry (1.00 1987-06-01 5.25\"/3.5\")", "1fe764e66857e7f305a5f03ca3f4971d"}, + {"mixedup", "Mixed Up Mother Goose (1987-11-10)", "e524655abf9b96a3b179ffcd1d0f79af"}, + {"pq1", "Police Quest (2.0E 1987-11-17)", "2fd992a92df6ab0461d5a2cd83c72139"}, + {"pq1", "Police Quest (2.0A 1987-10-23)", "b9dbb305092851da5e34d6a9f00240b1"}, + {"pq1", "Police Quest (2.0G 1987-12-03 5.25\"/ST)", "231f3e28170d6e982fc0ced4c98c5c1c"}, + {"pq1", "Police Quest (2.0G 1987-12-03)", "d194e5d88363095f55d5096b8e32fbbb"}, + {"sq1", "Space Quest I (1.1A 1986-11-13)", "8d8c20ab9f4b6e4817698637174a1cb6"}, + {"sq1", "Space Quest I (1.1A 720kb)", "0a92b1be7daf3bb98caad3f849868aeb"}, + {"sq1", "Space Quest I (1.0X 1986-09-24)", "af93941b6c51460790a9efa0e8cb7122"}, + {"sq1", "Space Quest I (2.2 1987-05-07 5.25\"/3.5\")", "5d67630aba008ec5f7f9a6d0a00582f4"}, + {"sq2", "Space Quest II (2.0D 1988-03-14 3.5\")", "85390bde8958c39830e1adbe9fff87f3"}, + {"sq2", "Space Quest II (2.0A 1987-11-06 5.25\")", "ad7ce8f800581ecc536f3e8021d7a74d"}, + {"sq2", "Space Quest II (2.0A 1987-11-06 3.5\")", "6c25e33d23b8bed42a5c7fa63d588e5c"}, + {"sq2", "Space Quest II (2.0C/A 5.25\"/ST)", "bd71fe54869e86945041700f1804a651"}, + {"sq2", "Space Quest II (2.0F 1989-01-05 3.5\")", "28add5125484302d213911df60d2aded"}, + {"xmascard", "Christmas Card (1986-11-13 [version 1])", "3067b8d5957e2861e069c3c0011bd43d"}, + {"agidemo", "Demo 3 1988-09-13", "289c7a2c881f1d973661e961ced77d74"}, + {"bc", "The Black Cauldron (2.10 1988-11-10 5.25\")", "0c5a9acbcc7e51127c34818e75806df6"}, + {"bc", "The Black Cauldron (2.10 1988-11-10 3.5\")", "0de3953c9225009dc91e5b0d1692967b"}, + {"goldrush", "Gold Rush! (2.01 1988-12-22 5.25\")", "db733d199238d4009a9e95f11ece34e9"}, + {"goldrush", "Gold Rush! (2.01 1988-12-22 3.5\")", "6a285235745f69b4b421403659497216"}, + {"goldrush", "Gold Rush! (2.01 1988-12-22)", "3ae052117feb483f01a9017025fbb366"}, + {"goldrush", "Gold Rush! (2.01 1988-12-22)", "1ef85c37fcf7224f9731f20f169c8c53"}, + {"goldrush", "Gold Rush! (3.0 1998-12-22 3.5\")", "6882b6090473209da4cd78bb59f78dbe"}, + {"kq4", "King's Quest IV (2.0 1988-07-27)", "f50f7f997208ca0e35b2650baec43a2d"}, + {"kq4", "King's Quest IV (2.0 1988-07-27 3.5\")", "fe44655c42f16c6f81046fdf169b6337"}, + {"kq4", "King's Quest IV (2.2 1988-09-27 3.5\")", "7470b3aeb49d867541fc66cc8454fb7d"}, + {"kq4", "King's Quest IV (2.3 1988-09-27)", "6d7714b8b61466a5f5981242b993498f"}, + {"kq4", "King's Quest IV (2.3 1988-09-27 3.5\")", "82a0d39af891042e99ac1bd6e0b29046"}, + {"kq4", "King's Quest IV Demo (1988-12-20)", "a3332d70170a878469d870b14863d0bf"}, + {"mh1", "Manhunter: New York (1.22 1988-08-31)", "0c7b86f05fe02c2e26cff1b07450b82a"}, + {"mh1", "Manhunter: New York (1.22 1988-08-31)", "5b625329021ad49fd0c1d6f2d6f54bba"}, + {"mh2", "Manhunter: San Francisco (3.02 1989-07-26 5.25\")", "bbb2c2f88d5740f7437fb7aa6f080b7b"}, + {"mh2", "Manhunter: San Francisco (3.02 1989-07-26 3.5\")", "6fb6f0ee2437704c409cf17e081ba152"}, + {"mh2", "Manhunter: San Francisco (3.03 1989-08-17 5.25\")", "b90e4795413c43de469a715fb3c1fa93"}, + {"fanmade","Advanced Epic Fighting", "6454e8c82a7351c8eef5927ad306af4f"}, + {"fanmade","AGI Combat", "0be6a8a9e19203dcca0067d280798871"}, + {"fanmade","AGI Combat (Beta)", "341a47d07be8490a488d0c709578dd10"}, + {"fanmade","AGI Contest 1 Template", "d879aed25da6fc655564b29567358ae2"}, + {"fanmade","AGI Contest 2 Template", "5a2fb2894207eff36c72f5c1b08bcc07"}, + {"fanmade","AGI Piano (v1.0)", "8778b3d89eb93c1d50a70ef06ef10310"}, + {"fanmade","AGI Quest (v1.46-TJ0)", "1cf1a5307c1a0a405f5039354f679814"}, + {"fanmade","AGI Tetris (1998)", "1afcbc25bfafded2d5fb82de9da0bd9a"}, + {"fanmade","AGI Trek (Demo)", "c02882b8a8245b629c91caf7eb78eafe"}, + {"fanmade","Acidopolis", "7017db1a4b726d0d59e65e9020f7d9f7"}, + {"fanmade","Agent 0055 (v1.0)", "c2b34a0c77acb05482781dda32895f24"}, + {"fanmade","Agent 06 vs. The Super Nazi", "136f89ca9f117c617e88a85119777529"}, + {"fanmade","Agent Quest", "59e49e8f72058a33c00d60ee1097e631"}, + {"fanmade","Al Pond - On Holiday (v1.0)", "a84975496b42d485920e886e92eed68b"}, + {"fanmade","Al Pond - On Holiday (v1.1)", "7c95ac4689d0c3bfec61e935f3093634"}, + {"fanmade","Al Pond - On Holiday (v1.3)", "8f30c260de9e1dd3d8b8f89cc19d2633"}, + {"fanmade","Al Pond 1 - Al Lives Forever (v1.0)", "e8921c3043b749b056ff51f56d1b451b"}, + {"fanmade","Al Pond 1 - Al Lives Forever (v1.3)", "fb4699474054962e0dbfb4cf12ca52f6"}, + {"fanmade","Apocalyptic Quest (v0.03 Teaser)", "42ced528b67965d3bc3b52c635f94a57"}, + {"fanmade","Apocalyptic Quest Demo 2003-06-24", "c68c49a37eaac73e5aa80ce7f05bbd72"}, + {"fanmade","Apocalyptic Quest 4.00 Alpha 2", "30c74d194840abc3fb1341b567743ac3"}, + {"fanmade","Beyond the Titanic 2", "9b8de38dc64ffb3f52b7877ea3ebcef9"}, + {"fanmade","Biri Quest 1", "1b08f34f2c43e626c775c9d6649e2f17"}, + {"fanmade","Bob The Farmboy", "e4b7df9d0830addee5af946d380e66d7"}, + {"fanmade","Botz", "a8fabe4e807adfe5ec02bfec6d983695"}, + {"fanmade","Brian's Quest (v1.0)", "0964aa79b9cdcff7f33a12b1d7e04b9c"}, + {"fanmade","CPU-21 (v1.0)", "35b7cdb4d17e890e4c52018d96e9cbf4"}, + {"fanmade","Car Driver (v1.1)", "2311611d2d36d20ccc9da806e6cba157"}, + {"fanmade","Cloak of Darkness (v1.0)", "5ba6e18bf0b53be10db8f2f3831ee3e5"}, + {"fanmade","Coco Coq (English) - Coco Coq In Grostesteing's Base (v.1.0.3)", "97631f8e710544a58bd6da9e780f9320"}, + {"fanmade","Coco Coq (French) - Coco Coq Dans la Base de Grostesteing (v1.0.2)", "ef579ebccfe5e356f9a557eb3b2d8649"}, + {"fanmade","Corby's Murder Mystery (v1.0)", "4ebe62ac24c5a8c7b7898c8eb070efe5"}, + {"fanmade","DG: The Adventure Game (v1.1)", "0d6376d493fa7a21ec4da1a063e12b25"}, + {"fanmade","DG: The Adventure Game (v1.1)", "258bdb3bb8e61c92b71f2f456cc69e23"}, + {"fanmade","Dashiki (16 Colors)", "9b2c7b9b0283ab9f12bedc0cb6770a07"}, + {"fanmade","Date Quest 1 (v1.0)", "ba3dcb2600645be53a13170aa1a12e69"}, + {"fanmade","Date Quest 2 (v1.0 Demo)", "1602d6a2874856e928d9a8c8d2d166e9"}, + {"fanmade","Date Quest 2 (v1.0)", "f13f6fc85aa3e6e02b0c20408fb63b47"}, + {"fanmade","Dave's Quest (v0.07)", "f29c3660de37bacc1d23547a167f27c9"}, + {"fanmade","Dave's Quest (v0.17)", "da3772624cc4a86f7137db812f6d7c39"}, + {"fanmade","Disco Nights (Demo)", "dc5a2b21182ba38bdcd992a3a978e690"}, + {"fanmade","Dogs Quest - The Quest for the Golden Bone (v1.0)", "f197357edaaea0ff70880602d2f09b3e"}, + {"fanmade","Dr. Jummybummy's Space Adventure", "988bd81785f8a452440a2a8ac67f96aa"}, + {"fanmade","Ed Ward", "98be839b9f30cbedea4c9cee5442d827"}, + {"fanmade","Elfintard", "c3b847e9e9e978af9708df76a0751dc2"}, + {"fanmade","Enclosure (v1.01)", "f08e66fee9ecdde77db7ee9a10c96ba2"}, + {"fanmade","Enclosure (v1.03)", "e4a0613ed02401502e506ba3565a8c40"}, + {"fanmade","Epic Fighting (v0.1)", "aff24a1b3bdd676187685c4d95ba4294"}, + {"fanmade","Escape Quest (v0.0.3)", "2346b65619b1da0298b715b06d1a45a1"}, + {"fanmade","Escape from the Desert (beta 1)", "dfdc634d340854bd6ece28024010758d"}, + {"fanmade","Escape from the Salesman", "e723ca4fe0f6f56affe039fbb4dbeb6c"}, + {"fanmade","Fu$k Quest 2 - Romancing the Bone (Teaser)", "d288355d71d9bb1639260ccaa3b2fbfe"}, + {"fanmade","Fu$k Quest 2 - Romancing the Bone", "294beeb7765c7ea6b05ed7b9bf7bff4f"}, + {"fanmade","Gennadi Tahab Autot - Mission Pack 1 - Kuressaare", "bfa5fe71978e6ccf3d4eedd430124015"}, + {"fanmade","Go West, Young Hippie", "ff31484ea465441cb5f3a0f8e956b716"}, + {"fanmade","Good Man (demo v3.41)", "3facd8a8f856b7b6e0f6c3200274d88c"}, + {"fanmade","Good Man (demo v4.0)", "d36f5d98cfcfd28cf7d4103906c59a77"}, + {"fanmade","Good Man (demo v4.0T)", "8184f70a5a33d4f407dfc8e9ddab99e9"}, + {"fanmade","Hank's Quest (v1.0 English) - Victim of Society", "64c15b3d0483d17888129100dc5af213"}, + {"fanmade","Hank's Quest (v1.1 English) - Victim of Society", "86d1f1dd9b0c4858d096e2a60cca8a14"}, + {"fanmade","Hank's Quest (v1.81 Dutch) - Slachtoffer Van Het Gebeuren", "41e53972d55ff3dff9e90d15fe1b659f"}, + {"fanmade","Hank's Quest (v1.81 English) - Victim of Society", "7a776383282f62a57c3a960dafca62d1"}, + {"fanmade","Herbao (v0.2)", "6a5186fc8383a9060517403e85214fc2"}, + {"fanmade","Hobbits", "4a1c1ef3a7901baf0ab45fde0cfadd89"}, + {"fanmade","Jack & Julia - VAMPYR", "8aa0b9a26f8d5a4421067ab8cc3706f6"}, + {"fanmade","Jeff's Quest (v.5 alpha Jun 1)", "10f1720eed40c12b02a0f32df3e72ded"}, + {"fanmade","Jeff's Quest (v.5 alpha May 31)", "51ff71c0ed90db4e987a488ed3bf0551"}, + {"fanmade","Jen's Quest (Demo 1)", "361afb5bdb6160213a1857245e711939"}, + {"fanmade","Jen's Quest (Demo 2)", "3c321eee33013b289ab8775449df7df2"}, + {"fanmade","Jiggy Jiggy Uh! Uh!", "bc331588a71e7a1c8840f6cc9b9487e4"}, + {"fanmade","Jimmy In: The Alien Attack (v0.1)", "a4e9db0564a494728de7873684a4307c"}, + {"fanmade","Joe McMuffin In \"What's Cooking, Doc\" (v1.0)", "8a3de7e61a99cb605fa6d233dd91c8e1"}, + {"fanmade","Journey Of Chef", "aa0a0b5a6364801ae65fdb96d6741df5"}, + {"fanmade","Jukebox (v1.0)", "c4b9c5528cc67f6ba777033830de7751"}, + {"fanmade","Justin Quest (v1.0 in development)", "103050989da7e0ffdc1c5e1793a4e1ec"}, + {"fanmade","Jõulumaa (v0.05)", "53982ecbfb907e41392b3961ad1c3475"}, + {"fanmade","Kings Quest 2 - Breast Intentions (v2.0 Mar 26)", "a25d7379d281b1b296d4785df90a8e78"}, + {"fanmade","Kings Quest 2 - Breast Intentions (v2.0 Aug 16)", "6b4f796d0421d2e12e501b511962e03a"}, + {"fanmade","Lasse Holm: The Quest for Revenge (v1.0)", "f9fbcc8a4ef510bfbb92423296ff4abb"}, + {"fanmade","Lawman for Hire", "c78b28bfd3767dd455b992cd8b7854fa"}, + {"fanmade","Lefty Goes on Vacation (Not in The Right Place)", "ccdc49a33870310b01f2c48b8a1f3c34"}, + {"fanmade","Les Inseparables (v1.0)", "4b780887cab0ecabc5eca319acb3acf2"}, + {"fanmade","Little Pirate (Demo 2 v0.6)", "437068efe4ec32d436da09d6f2ea56e1"}, + {"fanmade","Lost Eternity (v1.0)", "95f15c5632feb8a39e9ca3d9af35fcc9"}, + {"fanmade","MD Quest - The Search for Michiel (v0.10)", "2a6fcb21d2b5e4144c38ed817fabe8ee"}, + {"fanmade","Maale Adummin Quest", "ddfbeb33feb7cf78504fe4dba14ec63b"}, + {"fanmade","Monkey Man", "2322d03f997e8cc235d4578efff69cfa"}, + {"fanmade","Naturette 1 (English v1.2)", "0a75884e7f010974a230bdf269651117"}, + {"fanmade","Naturette 1 (English v1.3)", "f15bbf999ac55ebd404aa1eb84f7c1d9"}, + {"fanmade","Naturette 1 (French v1.2)", "d3665622cc41aeb9c7ecf4fa43f20e53"}, + {"fanmade","New AGI Hangman Test", "d69c0e9050ccc29fd662b74d9fc73a15"}, + {"fanmade","Nick's Quest - In Pursuit of QuakeMovie (v2.1 Gold)", "e29cbf9222551aee40397fabc83eeca0"}, + {"fanmade","Operation: Recon", "0679ce8405411866ccffc8a6743370d0"}, + {"fanmade","Patrick's Quest (Demo v1.0)", "f254f5b894b98fec5f92acc07fb62841"}, + {"fanmade","Phantasmagoria", "87d20c1c11aee99a4baad3797b63146b"}, + {"fanmade","Pharaoh Quest (v0.0)", "51c630899d076cf799e573dadaa2276d"}, + {"fanmade","Phil's Quest - the Search for Tolbaga", "5e7ca45c360e03164b8358e49900c588"}, + {"fanmade","Pinkun Maze Quest (v0.1)", "148ff0843af389928b3939f463bfd20d"}, + {"fanmade","Pirate Quest", "bb612a919ed2b9ea23bbf03ce69fed42"}, + {"fanmade","Pothead Quest (v0.1)", "d181101385d3a45082f418cd4b3c5b01"}, + {"fanmade","President's Quest", "4937d0e8ecadb7888faeb347799b0388"}, + {"fanmade","Prince Quest", "266248d75c3130c8ccc9c9bf2ad30a0d"}, + {"fanmade","Professor (English) - The Professor is Missing (Mar 17)", "6232de31cc204affdf2e92dfe3dc0e4d"}, + {"fanmade","Professor (English) - The Professor is Missing (Mar 22)", "b5fcf0ca2f0d1c073be82f01e2170961"}, + {"fanmade","Professor (French) - Le Professeur a Disparu", "7d9f8a4d4610bb9b0b97caa17590c2d3"}, + {"fanmade","Quest for Glory VI - Hero's Adventure", "d26765c3075064c80d284c5e06e33a7e"}, + {"fanmade","Quest for Home", "d2895dc1cd3930f2489af0f843b144b3"}, + {"fanmade","Quest for Ladies (demo v1.1 Apr 1)", "3f6e02f16e1154a0daf296c8895edd97"}, + {"fanmade","Quest for Ladies (demo v1.1 Apr 6)", "f75e7b6a0769a3fa926eea0854711591"}, + {"fanmade","Quest for Piracy 1 - Enter the Silver Pirate (v0.15)", "d23f5c2a26f6dc60c686f8a2436ea4a6"}, + {"fanmade","Quest for a Record Deal", "f4fbd7abf056d2d3204f790da5ac89ab"}, + {"fanmade","Ralph's Quest (v0.1)", "5cf56378aa01a26ec30f25295f0750ca"}, + {"fanmade","Residence 44 Quest (v0.99)", "7c5cc64200660c70240053b33d379d7d"}, + {"fanmade","Residence 44 Quest (v0.99)", "fe507851fddc863d540f2bec67cc67fd"}, + {"fanmade","Residence 44 Quest (v1.0a)", "f99e3f69dc8c77a45399da9472ef5801"}, + {"fanmade","SQ2Eye (v0.3)", "2be2519401d38ad9ce8f43b948d093a3"}, + {"fanmade","SQ2Eye (v0.41)", "f0e82c55f10eb3542d7cd96c107ae113"}, + {"fanmade","SQ2Eye (v0.42)", "d7beae55f6328ef8b2da47b1aafea40c"}, + {"fanmade","SQ2Eye (v0.43)", "2a895f06e45de153bb4b77c982009e06"}, + {"fanmade","SQ2Eye (v0.44)", "5174fc4b6d8a477ba0ff0575cd64e0aa"}, + {"fanmade","SQ2Eye (v0.45)", "6e06f8bb7b90ce6f6aabf1a0e620159c"}, + {"fanmade","SQ2Eye (v0.46)", "bf0ad7a035ff9113951d09d1efe380c4"}, + {"fanmade","SQ2Eye (v0.47)", "85dc3be1d33ff932c292b74f9037abaa"}, + {"fanmade","SQ2Eye (v0.48)", "587574252972a5b5c070a647973a9b4a"}, + {"fanmade","SQ2Eye (v0.481)", "fc9234beb49804ae869696ce5af8ef30"}, + {"fanmade","SQ2Eye (v0.482)", "3ed84b7b87fa6840f25c15f250a11ffb"}, + {"fanmade","SQ2Eye (v0.483)", "647c31298d3f9cda641231b893e347c0"}, + {"fanmade","SQ2Eye (v0.484)", "f2c86fae7b9046d408c62c8c49a4b882"}, + {"fanmade","SQ2Eye (v0.485)", "af59e36bc28f44545458b68a93e91e67"}, + {"fanmade","SQ2Eye (v0.486)", "3fd86436e93456770dbdd4593eded70a"}, + {"fanmade","Sarien", "314e5fdef17b803226d1de3af2e997ea"}, + {"fanmade","Save Santa (v1.0)", "4644f6beb5802081772f14be56ae196c"}, + {"fanmade","Save Santa (v1.3)", "f8afdb6efc5af5e7c0228b44633066af"}, + {"fanmade","Schiller (preview 1)", "ade39dea968c959cfebe1cf935d653e9"}, + {"fanmade","Schiller (preview 2)", "62cd1f8fc758bf6b4aa334e553624cef"}, + {"fanmade","Shifty (v1.0)", "2a07984d27b938364bf6bd243ac75080"}, + {"fanmade","Snowboarding Demo (v1.0)", "24bb8f29f1eddb5c0a099705267c86e4"}, + {"fanmade","Solar System Tour", "b5a3d0f392dfd76a6aa63f3d5f578403"}, + {"fanmade","Sorceror's Appraisal", "fe62615557b3cb7b08dd60c9d35efef1"}, + {"fanmade","Space Trek (v1.0)", "807a1aeadb2ace6968831d36ab5ea37a"}, + {"fanmade","Special Delivery", "88764dfe61126b8e73612c851b510a33"}, + {"fanmade","Speeder Bike Challenge (v1.0)", "2deb25bab379285ca955df398d96c1e7"}, + {"fanmade","Star Commander 1 - The Escape (v1.0)", "a7806f01e6fa14ebc029faa58f263750"}, + {"fanmade","Star Pilot: Bigger Fish", "8cb26f8e1c045b75c6576c839d4a0172"}, + {"fanmade","Tales of the Tiki", "8103c9c87e3964690a14a3d0d83f7ddc"}, + {"fanmade","Tex McPhilip 1 - Quest For The Papacy", "3c74b9a24b51aa8020ac82bee3132266"}, + {"fanmade","Tex McPhilip 2 - Road To Divinity (v1.5)", "7387e8df854440bc26620ca0ea43af9a"}, + {"fanmade","Tex McPhilip 3 - A Destiny of Sin (Demo v0.25)", "992d12031a486ad84e592ff5d7c9d782"}, + {"fanmade","Tex McPhilip 3 - A Destiny of Sin (v1.02)", "587d15e1106e59c33053c01b301ffe05"}, + {"fanmade","The 13th Disciple (v1.00)", "887719ad59afce9a41ec057dbb73ad73"}, + {"fanmade","The 13th Disciple (v1.01)", "58e3ec1b9ac1a79901c472aaa59db832"}, + {"fanmade","The Adventures of a Crazed Hermit", "6e3086cbb794d3299a9c5a9792295511"}, + {"fanmade","The Gourd of the Beans", "246f4d94946afb547482d44a53616d06"}, + {"fanmade","The Grateful Dead", "c2146631afacf8cb455ce24f3d2d46e7"}, + {"fanmade","The Legend of Shay-Larah 1 - The Lost Prince", "04e720c8e30c9cf12db22ea14a24a3dd"}, + {"fanmade","The Legend of Zelda: The Fungus of Time (Demo v1.00)", "dcaf8166ceb62a3d9b9aea7f3b197c09"}, + {"fanmade","The Legendary Harry Soupsmith (Demo 1998 Apr 2)", "64c46b0d6fc135c9835afa80980d2831"}, + {"fanmade","The Legendary Harry Soupsmith (Demo 1998 Aug 19)", "8d06d82970f2c591d880a95476efbcf0"}, + {"fanmade","The Long Haired Dude: Encounter of the 18-th Kind", "86ea17b9fc2f3e537a7e40863d352c29"}, + {"fanmade","The Lost Planet (v0.9)", "590dffcbd932a9fbe554be13b769cac0"}, + {"fanmade","The Lost Planet (v1.0)", "58564df8b6394612dd4b6f5c0fd68d44"}, + {"fanmade","The New Adventure of Roger Wilco (v1.00)", "e5f0a7cb8d49f66b89114951888ca688"}, + {"fanmade","The Ruby Cast (v0.02)", "ed138e461bb1516e097007e017ab62df"}, + {"fanmade","The Shadow Plan", "c02cd10267e721f4e836b1431f504a0a"}, + {"fanmade","The Sorceror's Appraisal", "b121ba95d2beb6c16e2f762a13b8baa2"}, + {"fanmade","Time Quest (Demo v0.1)", "12e1a6f03ea4b8c5531acd0400b4ed8d"}, + {"fanmade","Time Quest (Demo v0.2)", "7b710608abc99e0861ac59b967bf3f6d"}, + {"fanmade","Toby's World (Demo)", "3f8ebea0eb32303e65e2a6e8341c6741"}, + {"fanmade","Tonight The Shrieking Corpses Bleed (Demo v0.11)", "bcc57a7c8d563fa0c333107ae1c0a6e6"}, + {"fanmade","Tonight The Shrieking Corpses Bleed (v1.01)", "36b38f621b38e8d104aa0807302dc8c9"}, + {"fanmade","Turks' Quest - Heir to the Planet", "3d19254b737c8b218e5bc4580542b79a"}, + {"fanmade","Ultimate AGI Fangame (Demo)", "2d14d6fa2a2136d681e46e06821905bf"}, + {"fanmade","URI Quest (v0.173 Feb 27)", "3986eefcf546dafc45f920ae91a697c3"}, + {"fanmade","URI Quest (v0.173 Jan 29)", "494150940d34130605a4f2e67ee40b12"}, + {"fanmade","V - The Graphical Adventure", "c71f5c1e008d352ae9040b77fcf79327"}, + {"fanmade","Voodoo Girl - Queen of the Darned (v1.2 2002 Jan 1)", "ae95f0c77d9a97b61420fd192348b937"}, + {"fanmade","Voodoo Girl - Queen of the Darned (v1.2 2002 Mar 29)", "11d0417b7b886f963d0b36789dac4c8f"}, + {"fanmade","Wizaro (v0.1)", "abeec1eda6eaf8dbc52443ea97ff140c"}, + {"tetris", "", "7a874e2db2162e7a4ce31c9130248d8a"}, + {"caitlyn", "Demo", "5b8a3cdb2fc05469f8119d49f50fbe98"}, + {"caitlyn", "", "818469c484cae6dad6f0e9a353f68bf8"}, + {"fanmade", "Get Outta Space Quest", "aaea5b4a348acb669d13b0e6f22d4dc9"}, + {"sq0", "v1.03", "d2fd6f7404e86182458494e64375e590"}, + {"sq0", "v1.04", "2ad9d1a4624a98571ee77dcc83f231b6"}, + {"sq0", "", "e1a8e4efcce86e1efcaa14633b9eb986"}, + {"sqx", "v10.0 Feb 05", "c992ae2f8ab18360404efdf16fa9edd1"}, + {"sqx", "v10.0 Jul 18", "812edec45cefad559d190ffde2f9c910"}, + {"sqx", "v10.0", "f0a59044475a5fa37c055d8c3eb4d1a7"} + }; +} diff --git a/core/src/main/java/com/agifans/agile/EgaPalette.java b/core/src/main/java/com/agifans/agile/EgaPalette.java new file mode 100644 index 0000000..bc721c5 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/EgaPalette.java @@ -0,0 +1,106 @@ +package com.agifans.agile; + +import java.awt.Color; +import java.util.HashMap; +import java.util.Map; + +/** + * This class holds the 16 colours that make up the EGA palette. + * + * @author Lance Ewing + */ +public class EgaPalette { + + // The Color constants for the 16 EGA colours (and also the transparent colour we use). + public final static Color BLACK = new Color(0x000000); + public final static Color BLUE = new Color(0x0000AA); + public final static Color GREEN = new Color(0x00AA00); + public final static Color CYAN = new Color(0x00AAAA); + public final static Color RED = new Color(0xAA0000); + public final static Color MAGENTA = new Color(0xAA00AA); + public final static Color BROWN = new Color(0xAA5500); + public final static Color GREY = new Color(0xAAAAAA); + public final static Color DARKGREY = new Color(0x555555); + public final static Color LIGHTBLUE = new Color(0x5555FF); + public final static Color LIGHTGREEN = new Color(0x55FF55); + public final static Color LIGHTCYAN = new Color(0x55FFFF); + public final static Color PINK = new Color(0xFF5555); + public final static Color LIGHTMAGENTA = new Color(0xFF55FF); + public final static Color YELLOW = new Color(0xFFFF55); + public final static Color WHITE = new Color(0xFFFFFF); + + // JAGI RGB values + // 0x005454FC + + // RGB values for use in colors array. + public final static int black = BLACK.getRGB(); + public final static int blue = BLUE.getRGB(); + public final static int green = GREEN.getRGB(); + public final static int cyan = CYAN.getRGB(); + public final static int red = RED.getRGB(); + public final static int magenta = MAGENTA.getRGB(); + public final static int brown = BROWN.getRGB(); + public final static int grey = GREY.getRGB(); + public final static int darkgrey = DARKGREY.getRGB(); + public final static int lightblue = LIGHTBLUE.getRGB(); + public final static int lightgreen = LIGHTGREEN.getRGB(); + public final static int lightcyan = LIGHTCYAN.getRGB(); + public final static int pink = PINK.getRGB(); + public final static int lightmagenta = LIGHTMAGENTA.getRGB(); + public final static int yellow = YELLOW.getRGB(); + public final static int white = WHITE.getRGB(); + + private static short toRGB565(int argb8888) { + com.badlogic.gdx.graphics.Color color = new com.badlogic.gdx.graphics.Color(); + com.badlogic.gdx.graphics.Color.argb8888ToColor(color, argb8888); + return (short)com.badlogic.gdx.graphics.Color.rgb565(color); + } + + /** + * Holds a mapping from RGB8888 value to libgdx RGB565 value. + */ + public final static Map RGB888_TO_RGB565_MAP = new HashMap<>(); + static { + RGB888_TO_RGB565_MAP.put(black & 0xFFFFFF, toRGB565(black)); + RGB888_TO_RGB565_MAP.put(blue & 0xFFFFFF, toRGB565(blue)); + RGB888_TO_RGB565_MAP.put(green & 0xFFFFFF, toRGB565(green)); + RGB888_TO_RGB565_MAP.put(cyan & 0xFFFFFF, toRGB565(cyan)); + RGB888_TO_RGB565_MAP.put(red & 0xFFFFFF, toRGB565(red)); + RGB888_TO_RGB565_MAP.put(magenta & 0xFFFFFF, toRGB565(magenta)); + RGB888_TO_RGB565_MAP.put(brown & 0xFFFFFF, toRGB565(brown)); + RGB888_TO_RGB565_MAP.put(grey & 0xFFFFFF, toRGB565(grey)); + RGB888_TO_RGB565_MAP.put(darkgrey & 0xFFFFFF, toRGB565(darkgrey)); + RGB888_TO_RGB565_MAP.put(lightblue & 0xFFFFFF, toRGB565(lightblue)); + RGB888_TO_RGB565_MAP.put(lightgreen & 0xFFFFFF, toRGB565(lightgreen)); + RGB888_TO_RGB565_MAP.put(lightcyan & 0xFFFFFF, toRGB565(lightcyan)); + RGB888_TO_RGB565_MAP.put(pink & 0xFFFFFF, toRGB565(pink)); + RGB888_TO_RGB565_MAP.put(lightmagenta & 0xFFFFFF, toRGB565(lightmagenta)); + RGB888_TO_RGB565_MAP.put(yellow & 0xFFFFFF, toRGB565(yellow)); + RGB888_TO_RGB565_MAP.put(white & 0xFFFFFF, toRGB565(white)); + } + + /** + * Holds the RGB values for the 16 EGA colours. + */ + // TODO: Remove when satisfied that RGB565 is working. + //public final static int[] colours = { black, blue, green, cyan, red, magenta, brown, grey, darkgrey, lightblue, lightgreen, lightcyan, pink, lightmagenta, yellow, white }; + + public final static short[] colours = { + RGB888_TO_RGB565_MAP.get(black & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(blue & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(green & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(cyan & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(red & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(magenta & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(brown & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(grey & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(darkgrey & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(lightblue & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(lightgreen & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(lightcyan & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(pink & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(lightmagenta & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(yellow & 0xFFFFFF), + RGB888_TO_RGB565_MAP.get(white & 0xFFFFFF) + }; +} diff --git a/core/src/main/java/com/agifans/agile/GameScreen.java b/core/src/main/java/com/agifans/agile/GameScreen.java new file mode 100644 index 0000000..a48229a --- /dev/null +++ b/core/src/main/java/com/agifans/agile/GameScreen.java @@ -0,0 +1,59 @@ +package com.agifans.agile; + +import com.badlogic.gdx.graphics.Pixmap; +import com.badlogic.gdx.graphics.Texture; +import com.badlogic.gdx.utils.BufferUtils; + +public class GameScreen { + + /** + * The pixels array for the AGI screen. Any change made to this array will be copied + * to the Pixmap on every frame. + */ + private short[] pixels; + + private Pixmap screenPixmap; + private Texture[] screens; + private int drawScreen = 1; + private int updateScreen = 0; + + /** + * Constructor for GameScreen. + */ + public GameScreen() { + // Uses an approach used successfully in my various libgdx emulators. + pixels = new short[320 * 200]; + screenPixmap = new Pixmap(320, 200, Pixmap.Format.RGB565); + screens = new Texture[3]; + screens[0] = new Texture(screenPixmap, Pixmap.Format.RGB565, false); + screens[0].setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear); + screens[1] = new Texture(screenPixmap, Pixmap.Format.RGB565, false); + screens[1].setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear); + screens[2] = new Texture(screenPixmap, Pixmap.Format.RGB565, false); + screens[2].setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear); + } + + public boolean render() { + // TODO: After implementing web worker/separate background thread, need to response to message instead. + BufferUtils.copy(pixels, 0, screenPixmap.getPixels(), 320 * 200); + screens[updateScreen].draw(screenPixmap, 0, 0); + updateScreen = (updateScreen + 1) % 3; + drawScreen = (drawScreen + 1) % 3; + return true; + } + + public short[] getPixels() { + return pixels; + } + + public Texture getDrawScreen() { + return screens[drawScreen]; + } + + public void dispose() { + screenPixmap.dispose(); + screens[0].dispose(); + screens[1].dispose(); + screens[2].dispose(); + } +} diff --git a/core/src/main/java/com/agifans/agile/GameState.java b/core/src/main/java/com/agifans/agile/GameState.java new file mode 100644 index 0000000..18aa564 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/GameState.java @@ -0,0 +1,463 @@ +package com.agifans.agile; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import com.agifans.agile.agilib.Game; +import com.agifans.agile.agilib.Logic; +import com.agifans.agile.agilib.Objects; +import com.agifans.agile.agilib.Picture; +import com.agifans.agile.agilib.Sound; +import com.agifans.agile.agilib.View; +import com.agifans.agile.agilib.Words; + +/** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ +public class GameState { + + /** + * The Game whose data we are interpreting. + */ + private Game game; + + public Logic[] logics; + public Picture[] pictures; + public View[] views; + public Sound[] sounds; + public Objects objects; + public Words words; + + /** + * Scan start values for each Logic. Index is the Logic number. We normally start + * scanning the Logic at position 0, but this can be set to another value via the + * set.scan.start AGI command. Note that only loaded logics can have their scan + * offset set. When they are unloaded, their scan offset is forgotten. Logic 0 is + * always loaded, so its scan start is never forgotten. + */ + public int[] scanStart; + + public boolean[] controllers; + public int[] vars; + public boolean[] flags; + public String[] strings; + public AnimatedObject[] animatedObjects; + public AnimatedObject ego; + + /** + * The List of animated objects that currently have the DRAWN and UPDATE flags set. + */ + public List updateObjectList; + + /** + * The List of animated objects that have the DRAWN flag set but not the UPDATE flag. + */ + public List stoppedObjectList; + + /** + * A Map between a key event code and the matching controller number. + */ + public Map keyToControllerMap; + + /** + * For making random decisions. + */ + public Random random = new Random(); + + /** + * The Picture that is currently drawn, i.e. the last one for which a draw.pic() + * command was executed. This will be a clone of an instance in the Pictures array, + * which may have subsequently had an overlay drawn on top of it. + */ + public Picture currentPicture; + + /** + * The pixel array for the visual data for the current Picture, where the values + * are the ARGB values. The dimensions of this are 320x168, i.e. two pixels per + * AGI pixel. Makes it easier to copy to the main pixels array when required. + */ + public short[] visualPixels; + + /** + * The pixel array for the priority data for the current Picture, where the values + * are from 4 to 15 (i.e. they are not ARGB values). The dimensions of this one + * are 160x168 as its usage is non-visual. + */ + public int[] priorityPixels; + + /** + * The pixel array for the control line data for the current Picture, where the + * values are from 0 to 4 (i.e. not ARGB values). The dimensions of this one + * are 160x168 as its usage is non-visual. + */ + public int[] controlPixels; + + /** + * Whether or not the picture is currently visible. This is set to true after a + * show.pic call. The draw.pic and overlay.pic commands both set it to false. It's + * value is used to determine whether to render the AnimatedObjects. + */ + public boolean pictureVisible; + + public boolean acceptInput; + public boolean userControl; + public boolean graphicsMode; + public boolean showStatusLine; + public int statusLineRow; + public int pictureRow; + public int inputLineRow; + public int horizon; + public int textAttribute; + public int foregroundColour; + public int backgroundColour; + public char cursorCharacter; + public long totalTicks; + public long animationTicks; + public boolean gamePaused; + public int currentLogNum; + public StringBuilder currentInput; + public String lastInput; + public String gameId; + public String version; + public int maxDrawn; + public int priorityBase; + public String simpleName; + public boolean menuEnabled; + public boolean menuOpen; + public boolean holdKey; + + /** + * The List of recognised words from the current user input line. + */ + public List recognisedWords; + + /** + * Indicates that a block has been set. + */ + public boolean blocking; + + public short blockUpperLeftX; + public short blockUpperLeftY; + public short blockLowerRightX; + public short blockLowerRightY; + + /** + * Contains a transcript of events leading to the current state in the current room. + */ + public ScriptBuffer scriptBuffer; + + /** + * Returns true if the AGI game files are V3; otherwise false. + */ + public boolean isAGIV3() { return (game.v3GameSig != null); } + + /** + * Constructor for GameState. + * + * @param game The Game from which we'll get all of the game data. + */ + public GameState(Game game) { + this.game = game; + this.vars = new int[Defines.NUMVARS]; + this.flags = new boolean[Defines.NUMFLAGS]; + this.strings = new String[Defines.NUMSTRINGS]; + this.controllers = new boolean[Defines.NUMCONTROL]; + this.scanStart = new int[256]; + this.logics = new Logic[256]; + this.pictures = new Picture[256]; + this.views = new View[256]; + this.sounds = new Sound[256]; + this.objects = new Objects(game.objects); + this.words = game.words; + this.maxDrawn = 15; + this.priorityBase = 48; + this.statusLineRow = 21; + this.inputLineRow = 23; + this.currentInput = new StringBuilder(); + this.lastInput = ""; + this.simpleName = ""; + this.gameId = (game.v3GameSig != null? game.v3GameSig : "UNKNOWN"); + this.version = (game.version.equals("Unknown")? "2.917" : game.version); + this.menuEnabled = true; + this.holdKey = false; + this.keyToControllerMap = new HashMap<>(); + this.recognisedWords = new ArrayList<>(); + this.scriptBuffer = new ScriptBuffer(this); + + this.visualPixels = new short[320 * 168]; + this.priorityPixels = new int[160 * 168]; + this.controlPixels = new int[160 * 168]; + + // Create and initialise all of the AnimatedObject entries. + this.animatedObjects = new AnimatedObject[Defines.NUMANIMATED]; + for (int i=0; i < Defines.NUMANIMATED; i++) { + this.animatedObjects[i] = new AnimatedObject(this, i); + } + this.ego = this.animatedObjects[0]; + + this.updateObjectList = new ArrayList(); + this.stoppedObjectList = new ArrayList(); + + // Store resources in arrays for easy lookup. + this.logics = this.game.logics; + this.pictures = this.game.pictures; + this.views = this.game.views; + this.sounds = this.game.sounds; + + // Logic 0 is always marked as loaded. It never gets unloaded. + logics[0].isLoaded = true; + } + + /** + * Performs the initialisation of the state of the game being interpreted. Usually called whenever + * the game starts or restarts. + */ + public void init() { + clearStrings(); + clearVars(); + vars[Defines.MACHINE_TYPE] = 0; // IBM PC + vars[Defines.MONITOR_TYPE] = 3; // EGA + vars[Defines.INPUTLEN] = Defines.MAXINPUT + 1; + vars[Defines.NUM_VOICES] = 3; + + // The game would usually set this, but no harm doing it here (2 = NORMAL). + vars[Defines.ANIMATION_INT] = 2; + + // Set to the maximum memory amount as recognised by AGI. + vars[Defines.MEMLEFT] = 255; + + clearFlags(); + flags[Defines.HAS_NOISE] = true; + flags[Defines.INITLOGS] = true; + flags[Defines.SOUNDON] = true; + + // Set the text attribute to default (black on white), and display the input line. + foregroundColour = 15; + backgroundColour = 0; + + horizon = Defines.HORIZON; + userControl = true; + blocking = false; + + clearVisualPixels(); + graphicsMode = true; + acceptInput = false; + showStatusLine = false; + currentLogNum = 0; + currentInput.setLength(0); + lastInput = ""; + simpleName = ""; + clearControllers(); + menuEnabled = true; + holdKey = false; + + for (AnimatedObject aniObj : animatedObjects) { + aniObj.reset(true); + } + + stoppedObjectList.clear(); + updateObjectList.clear(); + + this.objects = new Objects(game.objects); + } + + /** + * Resets the four resources types back to their new room state. The main reason for doing + * this is to support the script event buffer. + */ + public void resetResources() { + for (int i = 0; i < 256; i++) { + // For Logics and Views, number 0 is never unloaded. + if (i > 0) { + if (logics[i] != null) logics[i].isLoaded = false; + } + if (views[i] != null) views[i].isLoaded = false; + if (pictures[i] != null) pictures[i].isLoaded = false; + if (sounds[i] != null) sounds[i].isLoaded = false; + } + } + + /** + * Restores all of the background save areas for the most recently drawn AnimatedObjects. + */ + public void restoreBackgrounds() { + // If no list specified, then restore update list then stopped list. + restoreBackgrounds(updateObjectList); + restoreBackgrounds(stoppedObjectList); + } + + /** + * Restores all of the background save areas for the most recently drawn AnimatedObjects. + * + * @param restoreList + */ + public void restoreBackgrounds(List restoreList) { + // Restore the backgrounds of the previous drawn cels for each AnimatedObject. + for (int i = restoreList.size(); --i >= 0;) { + restoreList.get(i).restoreBackPixels(); + } + } + + /** + * Draws all of the drawn AnimatedObjects in their priority / Y position order. This method + * does not actually render the objects to the screen but rather to the "back" screen, or + * "off" screen version of the visual screen. + */ + public void drawObjects() { + // If no list specified, then draw stopped list then update list. + drawObjects(makeStoppedObjectList()); + drawObjects(makeUpdateObjectList()); + } + + /** + * Draws all of the drawn AnimatedObjects in their priority / Y position order. This method + * does not actually render the objects to the screen but rather to the "back" screen, or + * "off" screen version of the visual screen. + * + * @param objectDrawList + */ + public void drawObjects(List objectDrawList) { + // Draw the AnimatedObjects to screen in priority order. + for (AnimatedObject aniObj : objectDrawList) { + aniObj.draw(); + } + } + + /** + * Shows all AnimatedObjects by blitting the bounds of their current cel to the screen + * pixels. Also updates the Stopped flag and previous position as per the original AGI + * interpreter behaviour. + * + * @param pixels The screen pixels to blit the AnimatedObjects to. + */ + public void showObjects(short[] pixels) { + // If no list specified, then draw stopped list then update list. + showObjects(pixels, stoppedObjectList); + showObjects(pixels, updateObjectList); + } + + /** + * Shows all AnimatedObjects by blitting the bounds of their current cel to the screen + * pixels. Also updates the Stopped flag and previous position as per the original AGI + * interpreter behaviour. + * + * @param pixels The screen pixels to blit the AnimatedObjects to. + * @param objectShowList + */ + public void showObjects(short[] pixels, List objectShowList) { + for (AnimatedObject aniObj : objectShowList) + { + aniObj.show(pixels); + + // Check if the AnimatedObject moved this cycle and if it did then set the flags accordingly. The + // position of an AnimatedObject is updated only when the StepTimeCount hits 0, at which point it + // reloads from StepTime. So if the values are equal, this is a step time reload cycle and therefore + // the AnimatedObject's position would have been updated and it is appropriate to update Stopped flag. + if (aniObj.stepTimeCount == aniObj.stepTime) + { + if ((aniObj.x == aniObj.prevX) && (aniObj.y == aniObj.prevY)) + { + aniObj.stopped = true; + } + else + { + aniObj.prevX = aniObj.x; + aniObj.prevY = aniObj.y; + aniObj.stopped = false; + } + } + } + } + + /** + * Returns a List of the AnimatedObjects to draw, in the order in which they should be + * drawn. It gets the list of candidate AnimatedObjects from the given GameState and + * then for each object that is in a Drawn state, it adds them to the list to be draw + * and then sorts that list by a combination of Y position and priority state, which + * results in the List to be drawn in the order they should be drawn. The updating param + * determines what the value of the Update flag should be in order to include an object + * in the list. + * + * @param objsToDraw > + * @param updating The value of the UPDATE flag to check for when adding to list + */ + public List makeObjectDrawList(List objsToDraw, boolean updating) { + objsToDraw.clear(); + + for (AnimatedObject aniObj : this.animatedObjects) { + if (aniObj.drawn && (aniObj.update == updating)) { + objsToDraw.add(aniObj); + } + } + + // Sorts them by draw order. + objsToDraw.sort(null); + + return objsToDraw; + } + + /** + * Recreates and then returns the list of animated objects that are currently + * being updated, in draw order. + */ + public List makeUpdateObjectList() { + return makeObjectDrawList(updateObjectList, true); + } + + /** + * Recreates and the returns the list of animated objects that are currently + * not being updated, in draw order. + */ + public List makeStoppedObjectList() { + return makeObjectDrawList(stoppedObjectList, false); + } + + /** + * Clears the VisualPixels screen to it's initial black state. + */ + public void clearVisualPixels() { + for (int i=0; i < this.visualPixels.length; i++) { + this.visualPixels[i] = EgaPalette.colours[0]; + } + } + + /** + * Clears all of the AGI variables to be zero. + */ + public void clearVars() { + for (int i = 0; i < Defines.NUMVARS; i++) { + vars[i] = 0; + } + } + + /** + * Clears all of the AGI flags to be false. + */ + public void clearFlags() { + for (int i = 0; i < Defines.NUMFLAGS; i++) { + flags[i] = false; + } + } + + /** + * Clears all of the AGI controllers to be false. + */ + public void clearControllers() { + for (int i = 0; i < Defines.NUMCONTROL; i++) { + controllers[i] = false; + } + } + + /** + * Clears all of the AGI Strings to be empty. + */ + public void clearStrings() { + for (int i = 0; i < Defines.NUMSTRINGS; i++) { + strings[i] = ""; + } + } +} diff --git a/core/src/main/java/com/agifans/agile/Interpreter.java b/core/src/main/java/com/agifans/agile/Interpreter.java new file mode 100644 index 0000000..51757db --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Interpreter.java @@ -0,0 +1,360 @@ +package com.agifans.agile; + +import com.agifans.agile.agilib.Game; +import com.badlogic.gdx.Input.Keys; + +/** + * Interpreter is the core class in the AGILE AGI interpreter. It controls the overall interpreter cycle. + */ +public class Interpreter { + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * Holds the data and state for user input events, such as keyboard and mouse input. + */ + private UserInput userInput; + + /** + * The pixels array for the AGI screen on which the background Picture and + * AnimatedObjects will be drawn to. + */ + private short[] pixels; + + /** + * Provides methods for drawing text on to the AGI screen. + */ + private TextGraphics textGraphics; + + /** + * Direct reference to AnimatedObject number one, i.e. ego, the main character. + */ + private AnimatedObject ego; + + /** + * Performs the execution of the LOGIC scripts. + */ + private Commands commands; + + /** + * Responsible for displaying the menu system. + */ + private Menu menu; + + /** + * Responsible for parsing the user input line to match known words. + */ + private Parser parser; + + /** + * Responsible for playing Sound resources. + */ + private SoundPlayer soundPlayer; + + /** + * Indicates that a thread is currently executing the Tick, i.e. a single interpretation + * cycle. This flag exists because there are some AGI commands that wait for something to + * happen before continuing. For example, a print window will stay up for a defined timeout + * period or until a key is pressed. In such cases, the thread can be in the Tick method + * for the duration of what would normally be many Ticks. + */ + private volatile boolean inTick; + + /** + * Constructor for Interpreter. + * + * @param game + * @param userInput + * @param wavePlayer + * @param pixels + */ + public Interpreter(Game game, UserInput userInput, WavePlayer wavePlayer, short[] pixels) { + this.state = new GameState(game); + this.userInput = userInput; + this.pixels = pixels; + this.textGraphics = new TextGraphics(pixels, state, userInput); + this.parser = new Parser(state); + this.soundPlayer = new SoundPlayer(state, wavePlayer); + this.menu = new Menu(state, textGraphics, pixels, userInput); + this.commands = new Commands(pixels, state, userInput, textGraphics, parser, soundPlayer, menu); + this.ego = state.ego; + this.state.init(); + this.textGraphics.updateInputLine(); + } + + /** + * Updates the internal AGI game clock. This method is invoked once a second. + */ + private void updateGameClock() { + if (++state.vars[Defines.SECONDS] >= 60) + { + // One minute has passed. + if (++state.vars[Defines.MINUTES] >= 60) + { + // One hour has passed. + if (++state.vars[Defines.HOURS] >= 24) + { + // One day has passed. + state.vars[Defines.DAYS]++; + state.vars[Defines.HOURS] = 0; + } + + state.vars[Defines.MINUTES] = 0; + } + + state.vars[Defines.SECONDS] = 0; + } + } + + /** + * Executes a single AGI interpreter tick, or cycle. This method is invoked 60 times a + * second, but the rate at which the logics are run and the animation updated is determined + * by the animation interval variable. + */ + public void tick() { + // Regardless of whether we're already in a Tick, we keep counting the number of Ticks. + state.totalTicks++; + + // Tick is called 60 times a second, so every 60th call, the second clock ticks. We + // deliberately do this outside of the main Tick block because some scripts wait for + // the clock to reach a certain clock value, which will never happen if the block isn't + // updated outside of the Tick block. + if ((state.totalTicks % 60) == 0) + { + updateGameClock(); + } + + // Only one thread can be running the core interpreter cycle at a time. + if (!inTick) + { + inTick = true; + + // Proceed only if the animation tick count has reached the set animation interval x 3. + if (++state.animationTicks < (state.vars[Defines.ANIMATION_INT] * 3)) + { + inTick = false; + return; + } + + // Reset animation tick count. + state.animationTicks = 0; + + // Clear controllers and get user input. + processUserInput(); + + // Update input line text on every cycle. + textGraphics.updateInputLine(false); + + // If ego is under program control, override user input as to his direction. + if (!state.userControl) + { + state.vars[Defines.EGODIR] = ego.direction; + } + else + { + ego.direction = (byte)state.vars[Defines.EGODIR]; + } + + // Calculate the direction in which objects will move, based on their MotionType. We do + // this here, i.e. call UpdateObjectDirections() before starting the logic scan, to + // allow ego's direction to be known to the logics even when ego is on a move.obj(). + updateObjectDirections(); + + // Store score and sound state prior to scanning LOGIC 0, so we can determine if they change. + int previousScore = state.vars[Defines.SCORE]; + boolean soundStatus = state.flags[Defines.SOUNDON]; + + // Continue scanning LOGIC 0 while the return value is true (which is what indicates a rescan). + while (commands.executeLogic(0)) + { + state.vars[Defines.OBJHIT] = 0; + state.vars[Defines.OBJEDGE] = 0; + state.vars[Defines.UNKNOWN_WORD] = 0; + state.flags[Defines.INPUT] = false; + previousScore = state.vars[Defines.SCORE]; + } + + // Set ego's direction from the variable. + ego.direction = (byte)state.vars[Defines.EGODIR]; + + // Update the status line, if the score or sound status have changed. + if ((state.vars[Defines.SCORE] != previousScore) || (soundStatus != state.flags[Defines.SOUNDON])) + { + // If the SOUND ON flag is off, then immediately stop any currently playing sound. + if (!state.flags[Defines.SOUNDON]) soundPlayer.stopSound(); + + textGraphics.updateStatusLine(); + } + + state.vars[Defines.OBJHIT] = 0; + state.vars[Defines.OBJEDGE] = 0; + + // Clear the restart, restore, & init logics flags. + state.flags[Defines.INITLOGS] = false; + state.flags[Defines.RESTART] = false; + state.flags[Defines.RESTORE] = false; + + // If in graphics mode, animate the AnimatedObjects. + if (state.graphicsMode) + { + animateObjects(); + } + + // If there is an open text window, we render it now. + if (textGraphics.isWindowOpen()) + { + textGraphics.drawWindow(); + } + + // Store what the key states were in this cycle before leaving. + for (int i = 0; i < 256; i++) userInput.oldKeys[i] = userInput.keys[i]; + + inTick = false; + } + } + + /** + * Fully shuts down the SoundPlayer. + */ + public void shutdownSound() { + soundPlayer.shutdown(); + } + + /** + * Animates each of the AnimatedObjects that are currently on the screen. This + * involves the cell cycling, the movement, and the drawing to the screen. + */ + private void animateObjects() { + // Ask each AnimatedObject to update its loop and cell number if required. + for (AnimatedObject aniObj : state.animatedObjects) { + aniObj.updateLoopAndCel(); + } + + state.vars[Defines.EGOEDGE] = 0; + state.vars[Defines.OBJHIT] = 0; + state.vars[Defines.OBJEDGE] = 0; + + // Restore the backgrounds of the previous drawn cels for each AnimatedObject. + state.restoreBackgrounds(state.updateObjectList); + + // Ask each AnimatedObject to move if it needs to. + for (AnimatedObject aniObj : state.animatedObjects) { + aniObj.updatePosition(); + } + + // Draw the AnimatedObjects to screen in priority order. + state.drawObjects(state.makeUpdateObjectList()); + state.showObjects(pixels, state.updateObjectList); + + // Clear the 'must be on water or land' bits for ego. + state.ego.stayOnLand = false; + state.ego.stayOnWater = false; + } + + /** + * Asks every AnimatedObject to calculate their direction based on their current state. + */ + private void updateObjectDirections() { + for (AnimatedObject aniObj : state.animatedObjects) { + aniObj.updateDirection(); + } + } + + /** + * Processes the user's input. + */ + private void processUserInput() { + state.clearControllers(); + state.flags[Defines.INPUT] = false; + state.flags[Defines.HADMATCH] = false; + state.vars[Defines.UNKNOWN_WORD] = 0; + state.vars[Defines.LAST_CHAR] = 0; + + // If opening of the menu was "triggered" in the last cycle, we open it now before processing the rest of the input. + if (state.menuOpen) { + menu.menuInput(); + } + + // F12 shows the priority and control screens. + if (userInput.keys[(int)Keys.F12] && !userInput.oldKeys[(int)Keys.F12]) { + commands.showPriorityScreen(); + } + + // Handle arrow keys. + if (state.userControl) { + if (state.holdKey) { + // In "hold key" mode, the ego direction directly reflects the direction key currently being held down. + byte direction = 0; + if (userInput.keys[(int)Keys.UP]) direction = 1; + if (userInput.keys[(int)Keys.PAGE_UP]) direction = 2; + if (userInput.keys[(int)Keys.RIGHT]) direction = 3; + if (userInput.keys[(int)Keys.PAGE_DOWN]) direction = 4; + if (userInput.keys[(int)Keys.DOWN]) direction = 5; + if (userInput.keys[(int)Keys.END]) direction = 6; + if (userInput.keys[(int)Keys.LEFT]) direction = 7; + if (userInput.keys[(int)Keys.HOME]) direction = 8; + state.vars[Defines.EGODIR] = direction; + } + else { + // Whereas in "release key" mode, the direction key press will toggle movement in that direction. + byte direction = 0; + if (userInput.keys[(int)Keys.UP] && !userInput.oldKeys[(int)Keys.UP]) direction = 1; + if (userInput.keys[(int)Keys.PAGE_UP] && !userInput.oldKeys[(int)Keys.PAGE_UP]) direction = 2; + if (userInput.keys[(int)Keys.RIGHT] && !userInput.oldKeys[(int)Keys.RIGHT]) direction = 3; + if (userInput.keys[(int)Keys.PAGE_DOWN] && !userInput.oldKeys[(int)Keys.PAGE_DOWN]) direction = 4; + if (userInput.keys[(int)Keys.DOWN] && !userInput.oldKeys[(int)Keys.DOWN]) direction = 5; + if (userInput.keys[(int)Keys.END] && !userInput.oldKeys[(int)Keys.END]) direction = 6; + if (userInput.keys[(int)Keys.LEFT] && !userInput.oldKeys[(int)Keys.LEFT]) direction = 7; + if (userInput.keys[(int)Keys.HOME] && !userInput.oldKeys[(int)Keys.HOME]) direction = 8; + if (direction > 0) { + state.vars[Defines.EGODIR] = (state.vars[Defines.EGODIR] == direction ? (byte)0 : direction); + } + } + } + + // Check all waiting characters. + int ch; + while ((ch = userInput.getKey()) > 0) { + + // Check controller matches. They take precedence. + if (state.keyToControllerMap.containsKey(ch)) { + state.controllers[state.keyToControllerMap.get(ch)] = true; + } + else if ((ch & 0xF0000) == UserInput.ASCII) { // Standard char from a keypress event. + char character = (char)(ch & 0xFF); + + state.vars[Defines.LAST_CHAR] = character; + + if (state.acceptInput) { + // Handle enter and backspace for user input line. + switch (character) { + case Character.ENTER: + if (state.currentInput.length() > 0) { + parser.parse(state.currentInput.toString()); + state.lastInput = state.currentInput.toString(); + state.currentInput.setLength(0); + } + break; + + case Character.BACKSPACE: + if (state.currentInput.length() > 0) { + state.currentInput.delete(state.currentInput.length() - 1, state.currentInput.length()); + } + break; + + default: + // Handle normal characters for user input line. + if ((state.strings[0].length() + (state.cursorCharacter > 0 ? 1 : 0) + state.currentInput.length()) < Defines.MAXINPUT) { + state.currentInput.append(character); + } + break; + } + } + } + } + } +} diff --git a/core/src/main/java/com/agifans/agile/Inventory.java b/core/src/main/java/com/agifans/agile/Inventory.java new file mode 100644 index 0000000..a39201c --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Inventory.java @@ -0,0 +1,227 @@ +package com.agifans.agile; + +import java.util.ArrayList; +import java.util.List; + +import com.badlogic.gdx.Input.Keys; + +/** + * The Inventory class handles the viewing of the player's inventory items. + */ +public class Inventory { + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * Holds the data and state for the user input, i.e. keyboard and mouse input. + */ + private UserInput userInput; + + /** + * Provides methods for drawing text on to the AGI screen. + */ + private TextGraphics textGraphics; + + /** + * The pixels array for the AGI screen, in which the text will be drawn. + */ + private short[] pixels; + + /** + * Constructor for Inventory. + * + * @param state Holds all of the data and state for the Game currently running. + * @param userInput Holds the data and state for the user input, i.e. keyboard and mouse input. + * @param textGraphics Provides methods for drawing text on to the AGI screen. + * @param pixels The pixels array for the AGI screen, in which the text will be drawn. + */ + public Inventory(GameState state, UserInput userInput, TextGraphics textGraphics, short[] pixels) { + this.state = state; + this.userInput = userInput; + this.textGraphics = textGraphics; + this.pixels = pixels; + } + + /** + * Used during the drawing of the inventory screen to represent a single inventory + * item name displayed in a specified cell of the two column inventory table. + */ + class InvItem { + public byte num; + public String name; + public int row; + public int col; + } + + /** + * Shows the inventory screen. Implements the AGI "status" command. + */ + public void showInventoryScreen() { + List invItems = new ArrayList(); + byte selectedItemIndex = 0; + int howMany = 0; + int row = 2; + + // Switch to the text screen. + textGraphics.textScreen(15); + + // Construct the table of objects being carried, deciding where on + // the screen they are to be printed as we go. + for (byte i=0; i < state.objects.objects.size(); i++) { + com.agifans.agile.agilib.Objects.Object obj = state.objects.objects.get(i); + if (obj.room == Defines.CARRYING) { + InvItem invItem = new InvItem(); + invItem.num = i; + invItem.name = obj.name; + invItem.row = row; + + if ((howMany & 1) == 0) { + invItem.col = 1; + } + else { + row++; + invItem.col = 39 - invItem.name.length(); + } + + if (i == state.vars[Defines.SELECTED_OBJ]) selectedItemIndex = (byte)invItems.size(); + + invItems.add(invItem); + howMany++; + } + } + + // If no objects in inventory, then say so. + if (howMany == 0) { + InvItem invItem = new InvItem(); + invItem.num = 0; + invItem.name = "nothing"; + invItem.row = row; + invItem.col = 16; + invItems.add(invItem); + } + + // Display the inventory items. + drawInventoryItems(invItems, invItems.get(selectedItemIndex)); + + // If we are not allowing an item to be selected, we simply wait for a key press then return. + if (!state.flags[Defines.ENABLE_SELECT]) { + userInput.waitForKey(); + } + else { + // Otherwise we handle movement between the items and selection of an item. + while (true) { + int key = userInput.waitForKey(); + if (key == (UserInput.ASCII | Character.ENTER)) { + state.vars[Defines.SELECTED_OBJ] = invItems.get(selectedItemIndex).num; + break; + } + else if (key == (UserInput.ASCII | Character.ESC)) { + state.vars[Defines.SELECTED_OBJ] = 0xFF; + break; + } + else if ((key == Keys.UP) || (key == Keys.DOWN) || (key == Keys.RIGHT) || (key == Keys.LEFT)) { + selectedItemIndex = moveSelect(invItems, key, selectedItemIndex); + } + } + } + + // Switch back to the graphics screen. + textGraphics.graphicsScreen(); + } + + /** + * Shows a special view of an object that has an attached description. Intended for use + * with the "look at object" scenario when the object looked at is an inventory item. + * + * @param viewNumber The number of the view to show the special inventory object view of. + */ + public void showInventoryObject(int viewNumber) { + // Set up the AnimatedObject that will be used to display this view. + AnimatedObject aniObj = new AnimatedObject(state, -1); + aniObj.setView(viewNumber); + aniObj.x = aniObj.prevX = (short)((Defines.MAXX - aniObj.xSize()) / 2); + aniObj.y = aniObj.prevY = Defines.MAXY; + aniObj.priority = 15; + aniObj.fixedPriority = true; + aniObj.previousCel = aniObj.cel(); + + // Display the description in a window along with the item picture. + textGraphics.windowPrint(state.views[viewNumber].description, aniObj); + + // Restore the pixels that were behind the item's image. + aniObj.restoreBackPixels(); + aniObj.show(pixels); + } + + /** + * Draws the table of inventory items. + * + * @param invItems The List of the items in the inventory table. + * @param selectedItem The currently selected item. + */ + private void drawInventoryItems(List invItems, InvItem selectedItem) { + textGraphics.drawString(this.pixels, "You are carrying:", 11 * 8, 0 * 8, 0, 15); + + for (InvItem invItem : invItems) { + if ((invItem == selectedItem) && state.flags[Defines.ENABLE_SELECT]) { + textGraphics.drawString(this.pixels, invItem.name, invItem.col * 8, invItem.row * 8, 15, 0); + } + else { + textGraphics.drawString(this.pixels, invItem.name, invItem.col * 8, invItem.row * 8, 0, 15); + } + } + + if (state.flags[Defines.ENABLE_SELECT]) { + textGraphics.drawString(this.pixels, "Press ENTER to select, ESC to cancel", 2 * 8, 24 * 8, 0, 15); + } + else { + textGraphics.drawString(this.pixels, "Press a key to return to the game", 4 * 8, 24 * 8, 0, 15); + } + } + + /** + * Processes the direction key that has been pressed. If within the bounds of the + * inventory List, a new selected item index will be returned and a new inventory + * item highlighted on the screen. + * + * @param invItems + * @param dirKey + * @param oldSelectedItemIndex + * + * @return The index of the new selected inventory item. + */ + private byte moveSelect(List invItems, int dirKey, byte oldSelectedItemIndex) { + byte newSelectedItemIndex = oldSelectedItemIndex; + + switch (dirKey) { + case Keys.UP: + newSelectedItemIndex -= 2; + break; + case Keys.RIGHT: + newSelectedItemIndex += 1; + break; + case Keys.DOWN: + newSelectedItemIndex += 2; + break; + case Keys.LEFT: + newSelectedItemIndex -= 1; + break; + } + + if ((newSelectedItemIndex < 0) || (newSelectedItemIndex >= invItems.size())) { + newSelectedItemIndex = oldSelectedItemIndex; + } + else { + InvItem previousItem = invItems.get(oldSelectedItemIndex); + InvItem newItem = invItems.get(newSelectedItemIndex); + textGraphics.drawString(this.pixels, previousItem.name, previousItem.col * 8, previousItem.row * 8, 0, 15); + textGraphics.drawString(this.pixels, newItem.name, newItem.col * 8, newItem.row * 8, 15, 0); + } + + return newSelectedItemIndex; + } +} diff --git a/core/src/main/java/com/agifans/agile/Menu.java b/core/src/main/java/com/agifans/agile/Menu.java new file mode 100644 index 0000000..c178cc3 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Menu.java @@ -0,0 +1,411 @@ +package com.agifans.agile; + +import java.util.ArrayList; +import java.util.List; + +import com.agifans.agile.TextGraphics.TextWindow; +import com.badlogic.gdx.Input.Keys; + +/** + * The Menu class is responsible for processing both the AGI commands that define the + * menus and their items and also for rendering the menu system when it is activated and + * processing the navigation and selection events while it is open. + */ +public class Menu { + + // Various static constants for calculating menu window dimensions and position. + private static final int CHARWIDTH = 4; + private static final int CHARHEIGHT = 8; + private static final int VMARGIN = 8; + private static final int HMARGIN = CHARWIDTH; + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * The pixels array for the AGI screen, in which the text will be drawn. + */ + private short[] pixels; + + /** + * Holds the data and state for the user input, i.e. keyboard and mouse input. + */ + private UserInput userInput; + + /** + * Provides methods for drawing text on to the AGI screen. + */ + private TextGraphics textGraphics; + + /** + * The List of the top level menu headers currently defined in the menu system. + */ + private List headers; + + /** + * The currently highlighted item in the currently open menu header. + */ + private MenuItem currentItem; + + /** + * The currently open menu header, i.e. the open whose items are currently being displayed. + */ + private MenuHeader currentHeader; + + private int menuCol; + private int itemRow; + private int itemCol; + + /** + * If set to true then this prevents further menu definition commands from being processed. + */ + private boolean menuSubmitted; + + class MenuHeader { + public MenuItem title; + public List items; + public MenuItem currentItem; + public int height; + } + + class MenuItem { + public String name; + public int row; + public int col; + public boolean enabled; + public int controller; + } + + /** + * static finalructor for Menu. + * + * @param state + * @param textGraphics + * @param pixels + * @param userInput + */ + public Menu(GameState state, TextGraphics textGraphics, short[] pixels, UserInput userInput) { + this.state = state; + this.textGraphics = textGraphics; + this.headers = new ArrayList(); + this.pixels = pixels; + this.userInput = userInput; + } + + /** + * Creates a new menu with the given name. + * + * @param menuName The name of the new menu. + */ + public void setMenu(String menuName) { + // We can't accept any more menu definitions if submit.menu has already been executed. + if (menuSubmitted) return; + + if (currentHeader == null) { + // The first menu header starts at column 1. + menuCol = 1; + } + else if (currentHeader.items.size() == 0) { + // If the last header didn't have any items, then disable it. + currentHeader.title.enabled = false; + } + + // Create a new MenuHeader. + MenuHeader header = new MenuHeader(); + + // Set the position of this menu name in the menu strip (leave two + // chars between menu titles). + header.title = new MenuItem(); + header.title.row = 0; + header.title.name = menuName; + header.title.col = menuCol; + header.title.enabled = true; + header.items = new ArrayList(); + header.height = 0; + + this.currentHeader = header; + this.headers.add(header); + + // Adjust the menu column for the next header. + menuCol += menuName.length() + 1; + + // Initialize stuff for the menu items to follow. + currentItem = null; + itemRow = 1; + } + + /** + * Creates a new menu item in the current menu, of the given name and mapped + * to the given controller number. + * + * @param itemName The name of the new menu item. + * @param controller The number of the controller to map this menu item to. + */ + public void setMenuItem(String itemName, int controller) { + // We can't accept any more menu definitions if submit.menu has already been executed. + if (menuSubmitted) return; + + // Create and define the new menu item and its position. + MenuItem menuItem = new MenuItem(); + menuItem.name = itemName; + menuItem.controller = controller; + if (itemRow == 1) { + if (currentHeader.title.col + itemName.length() < 39) { + itemCol = currentHeader.title.col; + } + else { + itemCol = 39 - itemName.length(); + } + } + menuItem.row = ++itemRow; + menuItem.col = itemCol; + menuItem.enabled = true; + + // Add the menu item to the current header's item list. + currentItem = menuItem; + currentHeader.items.add(menuItem); + currentHeader.height++; + if (currentHeader.currentItem == null) { + currentHeader.currentItem = menuItem; + } + } + + /** + * Signals to the menu system that the menu has now been fully defined. No further SetMenu + * or SetMenuItem calls will be processed. The current header and item is reset back to the + * first item in the first menu, ready for usage when the menu is activated. + */ + public void submitMenu() { + // If the last menu didn't have any items, disable it. + if (currentHeader.items.size() == 0) { + currentHeader.title.enabled = false; + } + + // Make the first menu the current one. + currentHeader = (headers.size() > 0? headers.get(0) : null); + currentItem = ((currentHeader != null) && (currentHeader.items.size() > 0) ? currentHeader.items.get(0) : null); + + // Remember that the submit has happened. We can't process menu definitions after submit.menu + menuSubmitted = true; + } + + /** + * Enables all MenuItems that map to the given controller number. + * + * @param controller The controller whose menu items should be enabled. + */ + public void enableItem(int controller) { + for (MenuHeader header : headers) { + for (MenuItem item : header.items) { + if (item.controller == controller) { + item.enabled = true; + } + } + } + } + + /** + * Enables all MenuItems. + */ + public void enableAllMenus() { + for (MenuHeader header : headers) { + for (MenuItem item : header.items) { + item.enabled = true; + } + } + } + + /** + * Disables all MenuItems that map to the given controller number. + * + * @param controller The controller whose menu items should be disabled. + */ + public void disableItem(int controller) { + for (MenuHeader header : headers) { + for (MenuItem item : header.items) { + if (item.controller == controller) { + item.enabled = false; + } + } + } + } + + /** + * Opens the menu system and processes all the navigation events until an item is either + * selected or the ESC key is pressed. + */ + public void menuInput() { + // Not sure why there is an ENABLE_MENU flag and the allow.menu command, but there is. + if (state.flags[Defines.ENABLE_MENU] && state.menuEnabled) { + // Clear the menu bar to white. + textGraphics.clearLines(0, 0, 15); + + // Draw each of the header titles in deselected mode. + for (MenuHeader header : headers) deselect(header.title); + + // Starts by showing the currently selected menu header and item. + showMenu(currentHeader); + + // Now we process all navigation keys until we the user either makes a selection + // or exits the menu system. + while (true) { + int index; + + switch (userInput.waitForKey()) { + + case (UserInput.ASCII | Character.ENTER): // Select the currently highlighted menu item. + if (!currentItem.enabled) continue; + state.controllers[currentItem.controller] = true; + putAwayMenu(currentHeader, currentItem); + restoreMenuLine(); + state.menuOpen = false; + return; + + case (UserInput.ASCII | Character.ESC): // Exit the menu system without a selection. + putAwayMenu(currentHeader, currentItem); + restoreMenuLine(); + state.menuOpen = false; + return; + + case Keys.UP: // Moving up within current menu. + deselect(currentItem); + index = (currentHeader.items.indexOf(currentItem) + currentHeader.items.size() - 1) % currentHeader.items.size(); + currentItem = currentHeader.items.get(index); + select(currentItem); + break; + + case Keys.PAGE_UP: // Move to top item of current menu. + deselect(currentItem); + currentItem = currentHeader.items.get(0); + select(currentItem); + break; + + case Keys.RIGHT: // Move to the menu on the right of the current menu.. + putAwayMenu(currentHeader, currentItem); + index = headers.indexOf(currentHeader); + do { currentHeader = headers.get((index = ((index + 1) % headers.size()))); } + while (!currentHeader.title.enabled); + currentItem = currentHeader.currentItem; + showMenu(currentHeader); + break; + + case Keys.PAGE_DOWN: // Move to bottom item of current menu. + deselect(currentItem); + currentItem = currentHeader.items.get(headers.size() - 1); + select(currentItem); + break; + + case Keys.DOWN: // Move down within current menu. + deselect(currentItem); + index = (currentHeader.items.indexOf(currentItem) + 1) % currentHeader.items.size(); + currentItem = currentHeader.items.get(index); + select(currentItem); + break; + + case Keys.END: // Move to the rightmost menu. + putAwayMenu(currentHeader, currentItem); + currentHeader = headers.get(headers.size() - 1); + currentItem = currentHeader.currentItem; + showMenu(currentHeader); + break; + + case Keys.LEFT: // Move left within current menu. + putAwayMenu(currentHeader, currentItem); + index = headers.indexOf(currentHeader); + do { currentHeader = headers.get((index = ((index + headers.size() - 1) % headers.size()))); } + while (!currentHeader.title.enabled); + currentItem = currentHeader.currentItem; + showMenu(currentHeader); + break; + + case Keys.HOME: // Move to the leftmost menu. + putAwayMenu(currentHeader, currentItem); + currentHeader = headers.get(0); + currentItem = currentHeader.currentItem; + showMenu(currentHeader); + break; + } + } + } + } + + /** + * Restores the state of what the menu line would have looked like prior to the menu being activated. + */ + private void restoreMenuLine() { + if (state.showStatusLine) { + textGraphics.updateStatusLine(); + } + else { + textGraphics.clearLines(0, 0, 0); + } + } + + /** + * Shows the menu items for the given MenuHeader. + * + * @param header The MenuHeader to show the menu items of. + */ + private void showMenu(MenuHeader header) { + // Interestingly, it would seem that the width is always calculated using the first item. The + // original AGI games tended to make the item names a consistent length within each menu. + MenuItem firstItem = (header.items.size() > 0 ? header.items.get(0) : null); + int height = header.height; + int width = (firstItem != null ? firstItem.name.length() : header.title.name.length()); + int column = (firstItem != null ? firstItem.col : header.title.col); + + // Compute window size and position and put them into the appropriate bytes of the words. + int menuDim = ((height * CHARHEIGHT + 2 * VMARGIN) << 8) | (width * CHARWIDTH + 2 * HMARGIN); + int menuPos = (((column - 1) * CHARWIDTH) << 8) | ((height + 1) * CHARHEIGHT + VMARGIN - 1); + + // Show the menu title as being selected. + select(header.title); + + // Open a window for this menu using the calculated position and dimensions. + textGraphics.openWindow(new TextWindow(menuPos, menuDim, 15, 0)); + + // Render each of the items in this menu. + for (MenuItem item : header.items) { + if (item == header.currentItem) { + select(item); + } + else { + deselect(item); + } + } + } + + /** + * Puts away the menu so that it is no longer displayed, but remembers what item + * in the list was selected at the time it was put away. + * + * @param header The MenuHeader representing the menu to put away. + * @param item The MenuItem that was currently selected in the menu when it was put away. + */ + private void putAwayMenu(MenuHeader header, MenuItem item) { + header.currentItem = item; + deselect(header.title); + textGraphics.closeWindow(); + } + + /** + * Renders the given MenuItem in a selected state. + * + * @param item The MenuItem to render in the selected state. + */ + private void select(MenuItem item) { + textGraphics.drawString(pixels, item.name, item.col * 8, item.row * 8, 15, 0, !item.enabled); + } + + /** + * Renders the given MenuItem in a deselected state. + * + * @param item The MenuItem to render in the deselected state. + */ + private void deselect(MenuItem item) { + textGraphics.drawString(pixels, item.name, item.col * 8, item.row * 8, 0, 15, !item.enabled); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/agifans/agile/Parser.java b/core/src/main/java/com/agifans/agile/Parser.java new file mode 100644 index 0000000..a02c2bd --- /dev/null +++ b/core/src/main/java/com/agifans/agile/Parser.java @@ -0,0 +1,188 @@ +package com.agifans.agile; + +import java.util.ArrayList; +import java.util.List; + +/** + * The Parser class is responsible for parsing the user input line to match known words and + * also to implement the 'said' and 'parse' commands. + */ +public class Parser { + + /** + * The List of word numbers for the recognised words from the current user input line. + */ + private List recognisedWordNumbers; + + /** + * These are the characters that separate words in the user input string (although + * usually it would be space). + */ + private String SEPARATORS = "[ ,.?!();:\\[\\]{}]+"; + + /** + * A regex matching the characters to be deleted from the user input string. + */ + private String IGNORE_CHARS = "['`\\-\"]"; + + /** + * Special word number that matches any word. + */ + private int ANYWORD = 1; + + /** + * Special word number that matches the rest of the line. + */ + private int REST_OF_LINE = 9999; + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * Constructor for Parser. + * + * @param state The GameState class holds all of the data and state for the Game currently being run. + */ + public Parser(GameState state) { + this.state = state; + this.recognisedWordNumbers = new ArrayList(); + } + + /** + * Parses the given user input line value. This is the method invoked by the main keyboard + * processing logic. After execution of this method, the RecognisedWords List will contain + * the words that were recognised from the input line, and the recognisedWordNumbers List + * will contain the word numbers for the recognised words. If the RecognisedWords List + * contains one more item than the recognisedWordNumbers List then the additional word + * will actually be an unrecognised word and the UNKNOWN_WORD var will contain the index + * of that word within the List + 1. The INPUT flag will be set if the RecognisedWords + * List contains at least one word. + * + * @param inputLine + */ + public void parse(String inputLine) { + // Clear the words matched from last time. + state.recognisedWords.clear(); + this.recognisedWordNumbers.clear(); + + // Remove ignored characters and collapse separators into a single space char. + String sanitisedInputLine = inputLine.toLowerCase().replaceAll(IGNORE_CHARS, "").replaceAll(SEPARATORS, " ").trim(); + + if (sanitisedInputLine.length() > 0) { + int inputLineStartPos = 0; + + while (inputLineStartPos < sanitisedInputLine.length()) { + // Scan backwards from the end of the input line, to the current input line start pos, to find the longest word match. + for (int inputLineEndPos = sanitisedInputLine.length(); inputLineEndPos >= inputLineStartPos; inputLineEndPos--) { + if ((inputLineEndPos == sanitisedInputLine.length()) || (sanitisedInputLine.charAt(inputLineEndPos) == ' ')) { + // This is the end of a word in the input line. Check if we have a match. + String wordToMatch = sanitisedInputLine.substring(inputLineStartPos, inputLineEndPos); + + if (state.words.wordToNumber.containsKey(wordToMatch)) { + // The word is recognised. This is the longest match possible, so let's get the word number for it. + int matchedWordNum = state.words.wordToNumber.get(wordToMatch); + + // If the word number is 0, it is ignored. + if (matchedWordNum > 0) { + // Otherwise store matched word details. + state.recognisedWords.add(wordToMatch); + this.recognisedWordNumbers.add(matchedWordNum); + } + + // Set the next start position to character after the separator that ended the matched word + // so that we can continue scanning the rest of the input line for more words. + inputLineStartPos = inputLineEndPos + 1; + break; + } + else if (wordToMatch.equals("a") || wordToMatch.equals("i")) { + // Skip "a" and "i". Move input line start position beyond it. + inputLineStartPos = inputLineEndPos + 1; + break; + } + else if (!wordToMatch.contains(" ")) { + // Unrecognised single word. Stores the word, use ANYWORD (word number 1, place holder for any word) + state.recognisedWords.add(wordToMatch); + this.recognisedWordNumbers.add(ANYWORD); + state.vars[Defines.UNKNOWN_WORD] = (byte)(state.recognisedWords.size()); + inputLineStartPos = sanitisedInputLine.length(); + break; + } + } + } + } + } + + if (state.recognisedWords.size() > 0) { + state.flags[Defines.INPUT] = true; + } + } + + /** + * Implements the 'parse' AGI command. What it does is to parse a string as if it + * was the normal user input line. It does this simply by calling the Parse method + * above with the value from the identified AGI string. It resets both the INPUT + * and HADMATCH flags prior to calling it so that the normal user input parsing + * state is cleared. The words will be available to all said() tests for the + * remainder of the current logic scan. + * + * @param strNum The number of the AGI string to parse the value of. + */ + public void parseString(int strNum) { + // Clear the state from the most recent parse. + state.flags[Defines.INPUT] = false; + state.flags[Defines.HADMATCH] = false; + + // If the given string number is less that the total number of strings. + if (strNum < Defines.NUMSTRINGS) { + // Parse the value of the string as if it was user input. + parse(state.strings[strNum]); + } + } + + /** + * Returns true if the number of non-ignored words in the input line is the same + * as that in the word list and the non-ignored words in the input match, in order, + * the words in the word list. The special word 'anyword' (or whatever is defined + * word list as word 1 in 'WORDS.TOK') matches any non-ignored word in the input. + * + * @param words The List of words to test if the user has said. + * + * @param true if the user has said the given words; otherwise false. + */ + public boolean said(List wordNumbers) { + // If there are no recognised words then we obviously didn't say what we're testing against. + if (this.recognisedWordNumbers.size() == 0) return false; + + // We should only perform the check if we have input, and there hasn't been a match already. + if (!state.flags[Defines.INPUT] || state.flags[Defines.HADMATCH]) return false; + + // Compare each word number in order. + for (int i=0; i < wordNumbers.size(); i++) { + int testWordNumber = wordNumbers.get(i); + + // If test word number matches the rest of the line, then it's a match. + if (testWordNumber == REST_OF_LINE) { + state.flags[Defines.HADMATCH] = true; + return true; + } + + // Exit if we have reached the end of the user entered words. No match. + if (i >= recognisedWordNumbers.size()) return false; + + int inputWordNumber = this.recognisedWordNumbers.get(i); + + // If word numbers don't match, and test word number doesn't represent anyword, then no match. + if ((testWordNumber != inputWordNumber) && (testWordNumber != ANYWORD)) return false; + } + + // If more words were entered than in the said, and there obviously wasn't a REST_OF_LINE, then no match. + if (state.recognisedWords.size() > wordNumbers.size()) return false; + + // Otherwise if we get this far without having exited already, it is a match. + state.flags[Defines.HADMATCH] = true; + return true; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/agifans/agile/QuitAction.java b/core/src/main/java/com/agifans/agile/QuitAction.java new file mode 100644 index 0000000..647e5fd --- /dev/null +++ b/core/src/main/java/com/agifans/agile/QuitAction.java @@ -0,0 +1,14 @@ +package com.agifans.agile; + +/** + * Not really an Exception as such. This is how we exit out of AGILE from the + * quit() AGI command. Rather than doing an immediate System.exit or something + * similar, the Interpreter will instead throw an instance of QuitAction, + * indicating that the Interpreter should exit cleanly. + */ +public class QuitAction extends RuntimeException { + + public static void exit() { + throw new QuitAction(); + } +} diff --git a/core/src/main/java/com/agifans/agile/SaveArea.java b/core/src/main/java/com/agifans/agile/SaveArea.java new file mode 100644 index 0000000..4a68759 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/SaveArea.java @@ -0,0 +1,20 @@ +package com.agifans.agile; + +/** + * Holds data about an AnimatedObject's background save area. + */ +public class SaveArea { + + public short x; + + public short y; + + public int width; + + public int height; + + public short[][] visBackPixels; + + public int[][] priBackPixels; + +} diff --git a/core/src/main/java/com/agifans/agile/SavedGames.java b/core/src/main/java/com/agifans/agile/SavedGames.java new file mode 100644 index 0000000..39dd50f --- /dev/null +++ b/core/src/main/java/com/agifans/agile/SavedGames.java @@ -0,0 +1,1195 @@ +package com.agifans.agile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.text.MessageFormat; +import java.util.Map.Entry; + +import com.agifans.agile.AnimatedObject.CycleType; +import com.agifans.agile.AnimatedObject.MotionType; +import com.agifans.agile.ScriptBuffer.ScriptBufferEvent; +import com.agifans.agile.ScriptBuffer.ScriptBufferEventType; +import com.agifans.agile.TextGraphics.TextWindow; +import com.badlogic.gdx.Input.Keys; + +/** + * A class or saving and restoring saved games. + */ +public class SavedGames { + + private static final int SAVENAME_LEN = 30; + private static final int NUM_GAMES = 12; + private static final int GAME_INDENT = 3; + private static final char POINTER_CHAR = (char)26; + private static final char ERASE_CHAR = (char)32; + + // Keeps track of whether it is the first time a save/restore is happening in simple mode. + private boolean firstTime = true; + + // Messages for the various window dialogs that are shown as part of the Save / Restore functionality. + private String simpleFirstMsg = "Use the arrow keys to move\n the pointer to your name.\nThen press ENTER\n"; + private String simpleSelectMsg = " Sorry, this disk is full.\nPosition pointer and press ENTER\n to overwrite a saved game\nor press ESC and try again \n with another disk\n"; + private String selectSaveMsg = "Use the arrow keys to select the slot in which you wish to save the game. Press ENTER to save in the slot, ESC to not save a game."; + private String selectRestoreMsg = "Use the arrow keys to select the game which you wish to restore. Press ENTER to restore the game, ESC to not restore a game."; + private String newDescriptMsg = "How would you like to describe this saved game?\n\n"; + private String noGamesMsg = "There are no games to\nrestore in\n\n{0}\n\nPress ENTER to continue."; + + // Data type for storing data about a single saved game file. + class SavedGame { + public int num; + public boolean exists; + public String fileName; + public long fileTime; + public String description; + public byte[] savedGameData; + } + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * Holds the data and state for the user input, i.e. keyboard and mouse input. + */ + private UserInput userInput; + + /** + * Provides methods for drawing text on to the AGI screen. + */ + private TextGraphics textGraphics; + + /** + * The pixels array for the AGI screen on which the background Picture and + * AnimatedObjects will be drawn to. + */ + private short[] pixels; + + /** + * Constructor for SavedGames. + * + * @param state + * @param userInput + * @param textGraphics + * @param pixels + */ + public SavedGames(GameState state, UserInput userInput, TextGraphics textGraphics, short[] pixels) { + this.state = state; + this.userInput = userInput; + this.textGraphics = textGraphics; + this.pixels = pixels; + } + + /** + * Chooses a saved game to either save to or restore from. The choice is either automatic + * such as in the case of simple save, or by the user. + * + * @param function 's' for save, 'r' for restore. + * + * @return + */ + private SavedGame chooseGame(char function) { + SavedGame[] game = new SavedGame[NUM_GAMES]; + int gameNum, numGames, mostRecentGame = 0; + long mostRecentTime = 0; + boolean simpleSave = (state.simpleName.length() > 0); + + try { + // Create saved game directory for this game if it doesn't yet exist. + Files.createDirectories(Paths.get(getSavePath())); + } catch (IOException ioe) { + // TODO: Handle this exception. + } + + // Look for the game files and get their data and meta data. + if (function == 's') { + // We're saving a game. + for (gameNum = 0; gameNum < NUM_GAMES; gameNum++) { + game[gameNum] = getGameByNumber(gameNum + 1); + + if (game[gameNum].exists && (game[gameNum].fileTime > mostRecentTime)) { + mostRecentTime = game[gameNum].fileTime; + mostRecentGame = gameNum; + } + } + + numGames = NUM_GAMES; + } + else { + // We're restoring a game. + for (gameNum = numGames = 0; gameNum < NUM_GAMES; gameNum++) { + game[numGames] = getGameByNumber(gameNum + 1); + + if (game[numGames].exists) { + if (game[numGames].fileTime > mostRecentTime) { + mostRecentTime = game[numGames].fileTime; + mostRecentGame = numGames; + } + + // Count how many saved games we currently have. + numGames++; + } + } + + if (numGames == 0) { + if (!simpleSave) { + // For normal save, if there are no games to display, tell the user so. + textGraphics.windowPrint(MessageFormat.format(noGamesMsg, getSavePath().replace("\\", "\\\\"))); + } + + // If there are no games to restore, exit at this point. + return null; + } + } + + if (simpleSave && !firstTime) { + // See if we have a slot for the current simple name value. + for (gameNum = 0; gameNum < NUM_GAMES; gameNum++) { + if (game[gameNum].description.equals(state.simpleName)) { + return (game[gameNum]); + } + } + + if (function == 's') { + // For simple save, we automatically find an empty slot for new saved game. + for (gameNum = 0; gameNum < NUM_GAMES; gameNum++) { + if ((game[gameNum].description == null) || (game[gameNum].description.equals(""))) { + // Description is automatically set to the SimpleName value if it is set. + game[gameNum].description = state.simpleName; + return (game[gameNum]); + } + } + } + + // If none available, fall thru to window. + + // We shouldn't be able to get to this point in restore mode, but just in case, return null. + if (function == 'r') return null; + } + + // Compute the height of the window desired and put it up + int descriptTop = 5; + int height = numGames + descriptTop; + TextWindow textWin = textGraphics.windowNoWait(simpleSave ? (firstTime ? simpleFirstMsg : simpleSelectMsg) : + (function == 's') ? selectSaveMsg : selectRestoreMsg, + height, SAVENAME_LEN + GAME_INDENT + 1, true); + + descriptTop += textWin.top; + firstTime = false; + + // Print the game descriptions within the open window.. + for (gameNum = 0; gameNum < numGames; gameNum++) { + textGraphics.drawString(this.pixels, MessageFormat.format(" - {0}", game[gameNum].description), + textWin.left * 8, (descriptTop + gameNum) * 8, 0, 15); + } + + // Put up the pointer, defaulting to most recently saved game, and then let the user start + // scrolling around with it to make a choice. + gameNum = mostRecentGame; + writePointer(textWin.left, descriptTop + gameNum); + + while (true) { + switch (userInput.waitForKey()) { + case (UserInput.ASCII | Character.ENTER): + if (simpleSave && (function == 'r')) { + // If this is a restore in simple save mode, it must be the first one, in which + // case we remember the selection in the SimpleName var so that it automatically + // restores next time the user restores. + state.simpleName = game[gameNum].description; + } + if (!simpleSave && (function == 's')) { + // If this is a save in normal save mode, then we ask the user to confirm/enter + // the description for the save game. + if ((game[gameNum].description = getWindowStr(newDescriptMsg, game[gameNum].description)) == null) { + // If they have pressed ESC, we return null to indicate not to continue. + return null; + } + } + textGraphics.closeWindow(); + return (game[gameNum]); + + case (UserInput.ASCII | Character.ESC): + textGraphics.closeWindow(); + return null; + + case Keys.UP: + erasePointer(textWin.left, descriptTop + gameNum); + gameNum = (gameNum == 0) ? numGames - 1 : gameNum - 1; + writePointer(textWin.left, descriptTop + gameNum); + break; + + case Keys.DOWN: + erasePointer(textWin.left, descriptTop + gameNum); + gameNum = (gameNum == numGames - 1) ? 0 : gameNum + 1; + writePointer(textWin.left, descriptTop + gameNum); + break; + } + } + } + + /** + * + * @param num + * + * @return + */ + private SavedGame getGameByNumber(int num) { + SavedGame theGame = new SavedGame(); + theGame.num = num; + + // Build full path to the saved game of this number for this game ID. + theGame.fileName = MessageFormat.format("{0}\\{1}SG.{2}", getSavePath(), state.gameId, num); + + File savedGameFile = new File(theGame.fileName); + theGame.savedGameData = new byte[(int)savedGameFile.length()]; + + try (FileInputStream fis = new FileInputStream(savedGameFile)) { + int bytesRead = fis.read(theGame.savedGameData); + if (bytesRead != savedGameFile.length()) { + theGame.description = ""; + theGame.exists = false; + return theGame; + } + } + catch (FileNotFoundException fnfe) { + // There is no saved game file of this name, so return false. + theGame.description = ""; + theGame.exists = false; + return theGame; + } + catch (Exception e) { + // Something unexpected happened. Bad file I guess. Return false. + theGame.description = ""; + theGame.exists = false; + return theGame; + } + + // Get last modified time as an epoch time, i.e. seconds since start of + // 1970 (which I guess must have been when the big bang was). + theGame.fileTime = ((new File(theGame.fileName)).lastModified() / 1000); + + // 0 - 30(31 bytes) SAVED GAME DESCRIPTION. + int textEnd = 0; + while (theGame.savedGameData[textEnd] != 0) textEnd++; + String savedGameDescription = new String(theGame.savedGameData, 0, textEnd, Charset.forName("Cp437")); + + // 33 - 39(7 bytes) Game ID("SQ2", "KQ3", "LLLLL", etc.), NUL padded. + textEnd = 33; + while ((theGame.savedGameData[textEnd] != 0) && ((textEnd - 33) < 7)) textEnd++; + String gameId = new String(theGame.savedGameData, 33, textEnd - 33, Charset.forName("Cp437")); + + // If the saved Game ID doesn't match the current, don't use this game. + if (!gameId.equals(state.gameId)) { + theGame.description = ""; + theGame.exists = false; + return theGame; + } + + // If we get this far, there is a valid saved game with this number for this game. + theGame.description = savedGameDescription; + theGame.exists = true; + return theGame; + } + + /** + * Displays the pointer character at the specified screen position. + * + * @param col + * @param row + */ + private void writePointer(int col, int row) { + textGraphics.drawChar(this.pixels, (byte)POINTER_CHAR, col * 8, row * 8, 0, 15); + } + + /** + * Erases the pointer character from the specified screen position. + * + * @param col + * @param row + */ + private void erasePointer(int col, int row) { + textGraphics.drawChar(this.pixels, (byte)ERASE_CHAR, col * 8, row * 8, 0, 15); + } + + /** + * Gets a String from the user by opening a window dialog. + * + * @param msg + * + * @return The entered text. + */ + private String getWindowStr(String msg) { + return getWindowStr(msg, ""); + } + + /** + * Gets a String from the user by opening a window dialog. + * + * @param msg + * @param str + * + * @return The entered text. + */ + private String getWindowStr(String msg, String str) { + // Open a new window with the message text displayed. + TextWindow textWin = textGraphics.windowNoWait(msg, 0, SAVENAME_LEN+1, true); + + // Clear the input row to black on top of the window. + textGraphics.clearRect(textWin.bottom, textWin.left, textWin.bottom, textWin.right - 1, 0); + + // Get the line of text from the user. + String line = textGraphics.getLine(SAVENAME_LEN, (byte)textWin.bottom, (byte)textWin.left, str, 15, 0); + + textGraphics.closeWindow(); + + return line; + } + + /** + * Gets the full path of the folder to use for reading and writing saved games. + * + * @return The full path of the folder to use for reading and writing saved games. + */ + private String getSavePath() { + // TODO: Will need an alternative approach when GWT is supported. + StringBuilder savedGamesPath = new StringBuilder(); + savedGamesPath.append(System.getProperty("user.home")); + savedGamesPath.append(System.getProperty("file.separator")); + savedGamesPath.append("Saved Games"); + savedGamesPath.append(System.getProperty("file.separator")); + savedGamesPath.append(state.gameId); + return savedGamesPath.toString(); + } + + /** + * Returns the length of the save variables part of a saved game. + * + * @param version The AGI interpreter version string. + * + * @return The length of the save variables part of a saved game + */ + private int getSaveVariablesLength(String version) { + switch (version) { + case "2.089": + case "2.272": + case "2.277": + return 0x03DB; + + case "2.411": + case "2.425": + case "2.426": + case "2.435": + case "2.439": + case "2.440": + return 0x05DF; + + case "3.002.102": + case "3.002.107": + // TODO: Not yet sure what the additional 3 bytes are used for. + return 0x05E4; + + case "3.002.149": + // This difference between 3.002.107 and 3.002.149 is that the latter has only 12 strings (12x40=480=0x1E0) + return 0x0404; + + // Default covers all the 2.9XX versions, 3.002.086 and 3.002.098. + default: + return 0x05E1; + } + } + + /** + * Returns the number of strings for the given AGI version. + * + * @param version The AGI version to return the number of strings for. + * + * @return The number of strings for the given AGI version. + */ + private int getNumberOfStrings(String version) { + switch (version) { + case "2.089": + case "2.272": + case "2.277": + case "3.002.149": + return 12; + // Most versions have 24 strings, as defined in the Defines constant. + default: + return Defines.NUMSTRINGS; + } + } + + /** + * Returns the number of controllers for the given AGI version. + * + * @param version The AGI version to return the number of controllers for. + * + * @return The number of controllers for the given AGI version. + */ + private int getNumberOfControllers(String version) { + switch (version) { + case "2.089": + case "2.272": + case "2.277": + return 40; + // Most versions have a max of 50 controllers, as defined in the Defines constant. + default: + return Defines.NUMCONTROL; + } + } + + /** + * Used to encrypt/decrypt the OBJECT section of the saved game file for AGI V3 games. Can't + * reuse the Objects class to do the crypting, as the saved game encrypts it from a different + * starting index, so the output is incompatible. + * + * @param data The byte array to crypt part of. + * @param start The start index to start crypting from. + * @param end The end index (exclusive) to crypt to. + */ + private void crypt(byte[] data, int start, int end) { + for (int i=0, j=start; j 0); + SavedGame savedGame = null; + + // Get the saved game file to save. + if ((savedGame = chooseGame('s')) == null) return; + + // If it is Simple Save mode then we skip asking them if they want to save. + if (!simpleSave) { + // Otherwise we prompt the user to confirm. + String msg = MessageFormat.format( + "About to save the game\ndescribed as:\n\n{0}\n\nin file:\n{1}\n\n{2}", + savedGame.description, savedGame.fileName.replace("\\", "\\\\"), + "Press ENTER to continue.\nPress ESC to cancel."); + textGraphics.windowNoWait(msg, 0, 35, false); + boolean abort = (userInput.waitAcceptAbort() == UserInput.ABORT); + textGraphics.closeWindow(); + if (abort) return; + } + + // No saved game will ever be as big as 20000, but we put that as a theoretical lid + // on the size based on rough calculations with all parts set to maximum size. We'll + // only write the bytes that use when created the file. + byte[] savedGameData = new byte[20000]; + int pos = 0; + + // 0 - 30(31 bytes) SAVED GAME DESCRIPTION. + for (byte b : savedGame.description.getBytes(Charset.forName("Cp437"))) { + savedGameData[pos++] = b; + } + + // FIRST PIECE: SAVE VARIABLES + // [0] 31 - 32(2 bytes) Length of save variables piece. Length depends on AGI interpreter version. + int saveVarsLength = getSaveVariablesLength(state.version); + int aniObjsOffset = 33 + saveVarsLength; + savedGameData[31] = (byte)(saveVarsLength & 0xFF); + savedGameData[32] = (byte)((saveVarsLength >> 8) & 0xFF); + + // [2] 33 - 39(7 bytes) Game ID("SQ2", "KQ3", "LLLLL", etc.), NUL padded. + pos = 33; + for (byte b : state.gameId.getBytes(Charset.forName("Cp437"))) { + savedGameData[pos++] = b; + } + + // [9] 40 - 295(256 bytes) Variables, 1 variable per byte + for (int i = 0; i < 256; i++) savedGameData[40 + i] = (byte)state.vars[i]; + + // [265] 296 - 327(32 bytes) Flags, 8 flags per byte + pos = 296; + for (int i = 0; i < 256; i+=8) { + savedGameData[pos++] = (byte)( + (state.flags[i + 0] ? 0x80 : 0x00) | (state.flags[i + 1] ? 0x40 : 0x00) | + (state.flags[i + 2] ? 0x20 : 0x00) | (state.flags[i + 3] ? 0x10 : 0x00) | + (state.flags[i + 4] ? 0x08 : 0x00) | (state.flags[i + 5] ? 0x04 : 0x00) | + (state.flags[i + 6] ? 0x02 : 0x00) | (state.flags[i + 7] ? 0x01 : 0x00)); + } + + // [297] 328 - 331(4 bytes) Clock ticks since game started. 1 clock tick == 50ms. + int saveGameTicks = (int)(state.totalTicks / 3); + savedGameData[328] = (byte)(saveGameTicks & 0xFF); + savedGameData[329] = (byte)((saveGameTicks >> 8) & 0xFF); + savedGameData[330] = (byte)((saveGameTicks >> 16) & 0xFF); + savedGameData[331] = (byte)((saveGameTicks >> 24) & 0xFF); + + // [301] 332 - 333(2 bytes) Horizon + savedGameData[332] = (byte)(state.horizon & 0xFF); + savedGameData[333] = (byte)((state.horizon >> 8) & 0xFF); + + // [303] 334 - 335(2 bytes) Key Dir + // TODO: Not entirely sure what this is for, so not currently saving this. + + // Currently active block. + // [305] 336 - 337(2 bytes) Upper left X position for active block. + savedGameData[336] = (byte)(state.blockUpperLeftX & 0xFF); + savedGameData[337] = (byte)((state.blockUpperLeftX >> 8) & 0xFF); + // [307] 338 - 339(2 bytes) Upper Left Y position for active block. + savedGameData[338] = (byte)(state.blockUpperLeftY & 0xFF); + savedGameData[339] = (byte)((state.blockUpperLeftY >> 8) & 0xFF); + // [309] 340 - 341(2 bytes) Lower Right X position for active block. + savedGameData[340] = (byte)(state.blockLowerRightX & 0xFF); + savedGameData[341] = (byte)((state.blockLowerRightX >> 8) & 0xFF); + // [311] 342 - 343(2 bytes) Lower Right Y position for active block. + savedGameData[342] = (byte)(state.blockLowerRightY & 0xFF); + savedGameData[343] = (byte)((state.blockLowerRightY >> 8) & 0xFF); + + // [313] 344 - 345(2 bytes) Player control (1) / Program control (0) + savedGameData[344] = (byte)(state.userControl ? 1 : 0); + // [315] 346 - 347(2 bytes) Current PICTURE number + savedGameData[346] = (byte)state.currentPicture.index; + // [317] 348 - 349(2 bytes) Blocking flag (1 = true, 0 = false) + savedGameData[348] = (byte)(state.blocking ? 1 : 0); + + // [319] 350 - 351(2 bytes) Max drawn. Always set to 15. Maximum number of animated objects that can be drawn at a time. Set by old max.drawn command in AGI v2.001. + savedGameData[350] = (byte)state.maxDrawn; + // [321] 352 - 353(2 bytes) Script size. Set by script.size. Max number of script event items. Default is 50. + savedGameData[352] = (byte)state.scriptBuffer.scriptSize; + // [323] 354 - 355(2 bytes) Current number of script event entries. + savedGameData[354] = (byte)state.scriptBuffer.scriptEntries(); + + // [325] 356 - 555(200 or 160 bytes) ? Key to controller map (4 bytes each). Earlier versions had less entries. + pos = 356; + int keyMapSize = getNumberOfControllers(state.version); + for (Entry entry : state.keyToControllerMap.entrySet()) { + if (entry.getKey() != 0) { + int keyCode = userInput.reverseKeyCodeMap.get(entry.getKey()); + int controllerNum = entry.getValue(); + savedGameData[pos++] = (byte)(keyCode & 0xFF); + savedGameData[pos++] = (byte)((keyCode >> 8) & 0xFF); + savedGameData[pos++] = (byte)(controllerNum & 0xFF); + savedGameData[pos++] = (byte)((controllerNum >> 8) & 0xFF); + } + } + + int postKeyMapOffset = 356 + (keyMapSize << 2); + + // [525] 556 - 1515(480 or 960 bytes) 12 or 24 strings, each 40 bytes long. For 2.4XX to 2.9XX, it was 24 strings. + int numOfStrings = getNumberOfStrings(state.version); + for (int i = 0; i < numOfStrings; i++) { + pos = postKeyMapOffset + (i * Defines.STRLENGTH); + if ((state.strings[i] != null) && (state.strings[i].length() > 0)) { + for (byte b : state.strings[i].getBytes(Charset.forName("Cp437"))) { + savedGameData[pos++] = b; + } + } + } + + int postStringsOffset = postKeyMapOffset + (numOfStrings * Defines.STRLENGTH); + + // [1485] 1516(2 bytes) Foreground colour + savedGameData[postStringsOffset + 0] = (byte)state.foregroundColour; + + // TODO: Need to fix the foreground and background colour storage. + + // [1487] 1518(2 bytes) Background colour + //int backgroundColour = (savedGameData[postStringsOffset + 2] + (savedGameData[postStringsOffset + 3] << 8)); + // TODO: Interpreter doesn't yet properly handle AGI background colour. + + // [1489] 1520(2 bytes) Text Attribute value (combined foreground/background value) + //int textAttribute = (savedGameData[postStringsOffset + 4] + (savedGameData[postStringsOffset + 5] << 8)); + + // [1491] 1522(2 bytes) Accept input = 1, Prevent input = 0 + savedGameData[postStringsOffset + 6] = (byte)(state.acceptInput ? 1 : 0); + + // [1493] 1524(2 bytes) User input row on the screen + savedGameData[postStringsOffset + 8] = (byte)state.inputLineRow; + + // [1495] 1526(2 bytes) Cursor character + savedGameData[postStringsOffset + 10] = (byte)state.cursorCharacter; + + // [1497] 1528(2 bytes) Show status line = 1, Don't show status line = 0 + savedGameData[postStringsOffset + 12] = (byte)(state.showStatusLine ? 1 : 0); + + // [1499] 1530(2 bytes) Status line row on the screen + savedGameData[postStringsOffset + 14] = (byte)state.statusLineRow; + + // [1501] 1532(2 bytes) Picture top row on the screen + savedGameData[postStringsOffset + 16] = (byte)state.pictureRow; + + // [1503] 1534(2 bytes) Picture bottom row on the screen + savedGameData[postStringsOffset + 18] = (byte)(state.pictureRow + 21); + + // [1505] 1536(2 bytes) Stores a pushed position within the script event list + // Note: Depends on interpreter version. 2.4xx and below didn't have push.script/pop.script, so they didn't have this saved game field. + if ((postStringsOffset + 20) < aniObjsOffset) { + // The spec is 2 bytes, but as with the fields above, there shouldn't be more than 255. + savedGameData[1536] = (byte)(state.scriptBuffer.savedScript); + } + + // Some AGI V3 versions have 3 additional bytes at this point. + // TODO: Work out what these 3 bytes are for and write them out here. + + // SECOND PIECE: ANIMATED OBJECT STATE + // 1538 - 1539(2 bytes) Length of piece + // Each ANIOBJ entry is 0x2B in length, i.e. 43 bytes. + int aniObjectsLength = ((state.objects.numOfAnimatedObjects + 1) * 0x2B); + savedGameData[aniObjsOffset + 0] = (byte)(aniObjectsLength & 0xFF); + savedGameData[aniObjsOffset + 1] = (byte)((aniObjectsLength >> 8) & 0xFF); + + for (int i=0; i < (state.objects.numOfAnimatedObjects + 1); i++) { + int aniObjOffset = aniObjsOffset + 2 + (i * 0x2B); + AnimatedObject aniObj = state.animatedObjects[i]; + + //UBYTE movefreq; /* number of animation cycles between motion */ e.g. 01 + savedGameData[aniObjOffset + 0] = (byte)aniObj.stepTime; + //UBYTE moveclk; /* number of cycles between moves of object */ e.g. 01 + savedGameData[aniObjOffset + 1] = (byte)aniObj.stepTimeCount; + //UBYTE num; /* object number */ e.g. 00 + savedGameData[aniObjOffset + 2] = aniObj.objectNumber; + //COORD x; /* current x coordinate */ e.g. 6e 00 (0x006e = ) + savedGameData[aniObjOffset + 3] = (byte)(aniObj.x & 0xFF); + savedGameData[aniObjOffset + 4] = (byte)((aniObj.x >> 8) & 0xFF); + //COORD y; /* current y coordinate */ e.g. 64 00 (0x0064 = ) + savedGameData[aniObjOffset + 5] = (byte)(aniObj.y & 0xFF); + savedGameData[aniObjOffset + 6] = (byte)((aniObj.y >> 8) & 0xFF); + //UBYTE view; /* current view number */ e.g. 00 + savedGameData[aniObjOffset + 7] = (byte)aniObj.currentView; + //VIEW* viewptr; /* pointer to current view */ e.g. 17 6b (0x6b17 = ) IGNORE. + //UBYTE loop; /* current loop in view */ e.g. 00 + savedGameData[aniObjOffset + 10] = (byte)aniObj.currentLoop; + //UBYTE loopcnt; /* number of loops in view */ e.g. 04 + if (aniObj.view() != null) savedGameData[aniObjOffset + 11] = (byte)aniObj.numberOfLoops(); + //LOOP* loopptr; /* pointer to current loop */ e.g. 24 6b (0x6b24 = ) IGNORE + //UBYTE cel; /* current cell in loop */ e.g. 00 + savedGameData[aniObjOffset + 14] = (byte)aniObj.currentCel; + //UBYTE celcnt; /* number of cells in current loop */ e.g. 06 + if (aniObj.view() != null) savedGameData[aniObjOffset + 15] = (byte)aniObj.numberOfCels(); + //CEL* celptr; /* pointer to current cell */ e.g. 31 6b (0x6b31 = ) IGNORE + //CEL* prevcel; /* pointer to previous cell */ e.g. 31 6b (0x6b31 = ) IGNORE + //STRPTR save; /* pointer to background save area */ e.g. 2f 9c (0x9c2f = ) IGNORE + //COORD prevx; /* previous x coordinate */ e.g. 6e 00 (0x006e = ) + savedGameData[aniObjOffset + 22] = (byte)(aniObj.prevX & 0xFF); + savedGameData[aniObjOffset + 23] = (byte)((aniObj.prevX >> 8) & 0xFF); + //COORD prevy; /* previous y coordinate */ e.g. 64 00 (0x0064 = ) + savedGameData[aniObjOffset + 24] = (byte)(aniObj.prevY & 0xFF); + savedGameData[aniObjOffset + 25] = (byte)((aniObj.prevY >> 8) & 0xFF); + //COORD xsize; /* x dimension of current cell */ e.g. 06 00 (0x0006 = ) + if (aniObj.view() != null) savedGameData[aniObjOffset + 26] = (byte)(aniObj.xSize() & 0xFF); + if (aniObj.view() != null) savedGameData[aniObjOffset + 27] = (byte)((aniObj.xSize() >> 8) & 0xFF); + //COORD ysize; /* y dimension of current cell */ e.g. 20 00 (0x0020 = ) + if (aniObj.view() != null) savedGameData[aniObjOffset + 28] = (byte)(aniObj.ySize() & 0xFF); + if (aniObj.view() != null) savedGameData[aniObjOffset + 29] = (byte)((aniObj.ySize() >> 8) & 0xFF); + //UBYTE stepsize; /* distance object can move */ e.g. 01 + savedGameData[aniObjOffset + 30] = (byte)aniObj.stepSize; + //UBYTE cyclfreq; /* time interval between cells of object */ e.g. 01 + savedGameData[aniObjOffset + 31] = (byte)aniObj.cycleTime; + //UBYTE cycleclk; /* counter for determining when object cycles */ e.g. 01 + savedGameData[aniObjOffset + 32] = (byte)aniObj.cycleTimeCount; + //UBYTE dir; /* object direction */ e.g. 00 + savedGameData[aniObjOffset + 33] = aniObj.direction; + //UBYTE motion; /* object motion type */ e.g. 00 + // #define WANDER 1 /* random movement */ + // #define FOLLOW 2 /* follow an object */ + // #define MOVETO 3 /* move to a given coordinate */ + savedGameData[aniObjOffset + 34] = (byte)aniObj.motionType.ordinal(); + //UBYTE cycle; /* cell cycling type */ e.g. 00 + // #define NORMAL 0 /* normal repetative cycling of object */ + // #define ENDLOOP 1 /* animate to end of loop and stop */ + // #define RVRSLOOP 2 /* reverse of ENDLOOP */ + // #define REVERSE 3 /* cycle continually in reverse */ + savedGameData[aniObjOffset + 35] = (byte)aniObj.cycleType.ordinal(); + //UBYTE pri; /* priority of object */ e.g. 09 + savedGameData[aniObjOffset + 36] = aniObj.priority; + + //UWORD control; /* object control flag (bit mapped) */ e.g. 53 40 (0x4053 = ) + int controlBits = + (aniObj.drawn ? 0x0001 : 0x00) | + (aniObj.ignoreBlocks ? 0x0002 : 0x00) | + (aniObj.fixedPriority ? 0x0004 : 0x00) | + (aniObj.ignoreHorizon ? 0x0008 : 0x00) | + (aniObj.update ? 0x0010 : 0x00) | + (aniObj.cycle ? 0x0020 : 0x00) | + (aniObj.animated ? 0x0040 : 0x00) | + (aniObj.blocked ? 0x0080 : 0x00) | + (aniObj.stayOnWater ? 0x0100 : 0x00) | + (aniObj.ignoreObjects ? 0x0200 : 0x00) | + (aniObj.repositioned ? 0x0400 : 0x00) | + (aniObj.stayOnLand ? 0x0800 : 0x00) | + (aniObj.noAdvance ? 0x1000 : 0x00) | + (aniObj.fixedLoop ? 0x2000 : 0x00) | + (aniObj.stopped ? 0x4000 : 0x00); + savedGameData[aniObjOffset + 37] = (byte)(controlBits & 0xFF); + savedGameData[aniObjOffset + 38] = (byte)((controlBits >> 8) & 0xFF); + + //UBYTE parms[4]; /* space for various motion parameters */ e.g. 00 00 00 00 + savedGameData[aniObjOffset + 39] = (byte)aniObj.motionParam1; + savedGameData[aniObjOffset + 40] = (byte)aniObj.motionParam2; + savedGameData[aniObjOffset + 41] = (byte)aniObj.motionParam3; + savedGameData[aniObjOffset + 42] = (byte)aniObj.motionParam4; + } + + // THIRD PIECE: OBJECTS + // Almost an exact copy of the OBJECT file, but with the 3 byte header removed, and room + // numbers reflecting the current location of each object. + byte[] objectData = state.objects.encode(); + int objectsOffset = aniObjsOffset + 2 + aniObjectsLength; + int objectsLength = objectData.length - 3; + savedGameData[objectsOffset + 0] = (byte)(objectsLength & 0xFF); + savedGameData[objectsOffset + 1] = (byte)((objectsLength >> 8) & 0xFF); + pos = objectsOffset + 2; + if (state.isAGIV3()) { + // AGI V3 games xor encrypt the data with Avis Durgan. Note that unlike the OBJECT + // file itself, the saved game OBJECT section crypts from index 3, since it does + // not output the 3 byte header, so starts the crypting after that. + crypt(objectData, 3, objectData.length); + } + for (int i=3; i> 8) & 0xFF); + pos = scriptsOffset + 2; + for (int i = 0; i < scriptEventData.length; i++) { + savedGameData[pos++] = scriptEventData[i]; + } + + // FIFTH PIECE: SCAN OFFSETS + int scanOffsetsOffset = scriptsOffset + 2 + scriptsLength; + int loadedLogicCount = 0; + // There is a scan offset for each loaded logic. + for (ScriptBufferEvent e : state.scriptBuffer.events) if (e.type == ScriptBufferEventType.LOAD_LOGIC) loadedLogicCount++; + // The scan offset data contains the offsets for loaded logics plus a 4 byte header, 4 bytes for logic 0, and 4 byte trailer. + int scanOffsetsLength = (loadedLogicCount * 4) + 12; + savedGameData[scanOffsetsOffset + 0] = (byte)(scanOffsetsLength & 0xFF); + savedGameData[scanOffsetsOffset + 1] = (byte)((scanOffsetsLength >> 8) & 0xFF); + pos = scanOffsetsOffset + 2; + // The scan offsets start with 00 00 00 00. + savedGameData[pos++] = 0; + savedGameData[pos++] = 0; + savedGameData[pos++] = 0; + savedGameData[pos++] = 0; + // And this is then always followed by an entry for Logic 0 + savedGameData[pos++] = 0; + savedGameData[pos++] = 0; + savedGameData[pos++] = (byte)(state.scanStart[0] & 0xFF); + savedGameData[pos++] = (byte)((state.scanStart[0] >> 8) & 0xFF); + // The scan offsets for the rest are stored in the order in which the logics were loaded. + for (ScriptBufferEvent e : state.scriptBuffer.events) { + if (e.type == ScriptBufferEventType.LOAD_LOGIC) { + int logicNum = e.resourceNumber; + int scanOffset = state.scanStart[logicNum]; + savedGameData[pos++] = (byte)(logicNum & 0xFF); + savedGameData[pos++] = (byte)((logicNum >> 8) & 0xFF); + savedGameData[pos++] = (byte)(scanOffset & 0xFF); + savedGameData[pos++] = (byte)((scanOffset >> 8) & 0xFF); + } + } + // The scan offset section ends with FF FF 00 00. + savedGameData[pos++] = (byte)0xFF; + savedGameData[pos++] = (byte)0xFF; + savedGameData[pos++] = 0; + savedGameData[pos++] = 0; + + // Write out the saved game data to the file. + try { + try (FileOutputStream outputStream = new FileOutputStream(savedGame.fileName)) { + outputStream.write(savedGameData, 0, pos); + } + } + catch (Exception e) { + this.textGraphics.print("Error in saving game.\nPress ENTER to continue."); + } + } + + /** + * Restores the GameState of the Interpreter from a saved game file. + * + * @return true if a game was restored; otherwise false + */ + public boolean restoreGameState() { + boolean simpleSave = (state.simpleName.length() > 0); + SavedGame savedGame = null; + + // Get the saved game file to restore. + if ((savedGame = chooseGame('r')) == null) return false; + + // If it is Simple Save mode then we skip asking them if they want to restore. + if (!simpleSave) { + // Otherwise we prompt the user to confirm. + String msg = MessageFormat.format( + "About to restore the game\ndescribed as:\n\n{0}\n\nfrom file:\n{1}\n\n{2}", + savedGame.description, savedGame.fileName.replace("\\", "\\\\"), + "Press ENTER to continue.\nPress ESC to cancel."); + textGraphics.windowNoWait(msg, 0, 35, false); + boolean abort = (userInput.waitAcceptAbort() == UserInput.ABORT); + textGraphics.closeWindow(); + if (abort) return false; + } + + byte[] savedGameData = savedGame.savedGameData; + + // 0 - 30(31 bytes) SAVED GAME DESCRIPTION. + int textEnd = 0; + while (savedGameData[textEnd] != 0) textEnd++; + String savedGameDescription = new String(savedGameData, 0, textEnd, Charset.forName("Cp437")); + + // FIRST PIECE: SAVE VARIABLES + // [0] 31 - 32(2 bytes) Length of save variables piece. Length depends on AGI interpreter version. [e.g. (0xE1 0x05) for some games, (0xDB 0x03) for some] + int saveVarsLength = (savedGameData[31] & 0xFF) + ((savedGameData[32] & 0xFF) << 8); + int aniObjsOffset = 33 + saveVarsLength; + + // [2] 33 - 39(7 bytes) Game ID("SQ2", "KQ3", "LLLLL", etc.), NUL padded. + textEnd = 33; + while ((savedGameData[textEnd] != 0) && ((textEnd - 33) < 7)) textEnd++; + String gameId = new String(savedGameData, 33, textEnd - 33, Charset.forName("Cp437")); + if (!gameId.equals(state.gameId)) return false; + + // If we're sure that this saved game file is for this game, then continue. + state.init(); + textGraphics.clearLines(0, 24, 0); + + // [9] 40 - 295(256 bytes) Variables, 1 variable per byte + for (int i=0; i<256; i++) state.vars[i] = (savedGameData[40 + i] & 0xFF); + + // [265] 296 - 327(32 bytes) Flags, 8 flags per byte + for (int i=0; i<256; i++) state.flags[i] = ((savedGameData[(i >> 3) + 296] & 0xFF) & (0x80 >> (i & 0x07))) > 0; + + // [297] 328 - 331(4 bytes) Clock ticks since game started. 1 clock tick == 50ms. + state.totalTicks = ((savedGameData[328] & 0xFF) + ((savedGameData[329] & 0xFF) << 8) + ((savedGameData[330] & 0xFF) << 16) + ((savedGameData[331] & 0xFF) << 24)) * 3; + + // [301] 332 - 333(2 bytes) Horizon + state.horizon = ((savedGameData[332] & 0xFF) + ((savedGameData[333] & 0xFF) << 8)); + + // [303] 334 - 335(2 bytes) Key Dir + // TODO: Not entirely sure what this is for. + int keyDir = ((savedGameData[334] & 0xFF) + ((savedGameData[335] & 0xFF) << 8)); + + // Currently active block. + // [305] 336 - 337(2 bytes) Upper left X position for active block. + state.blockUpperLeftX = (short)((savedGameData[336] & 0xFF) + ((savedGameData[337] & 0xFF) << 8)); + // [307] 338 - 339(2 bytes) Upper Left Y position for active block. + state.blockUpperLeftY = (short)((savedGameData[338] & 0xFF) + ((savedGameData[339] & 0xFF) << 8)); + // [309] 340 - 341(2 bytes) Lower Right X position for active block. + state.blockLowerRightX = (short)((savedGameData[340] & 0xFF) + ((savedGameData[341] & 0xFF) << 8)); + // [311] 342 - 343(2 bytes) Lower Right Y position for active block. + state.blockLowerRightY = (short)((savedGameData[342] & 0xFF) + ((savedGameData[343] & 0xFF) << 8)); + + // [313] 344 - 345(2 bytes) Player control (1) / Program control (0) + state.userControl = ((savedGameData[344] & 0xFF) + ((savedGameData[345] & 0xFF) << 8)) == 1; + // [315] 346 - 347(2 bytes) Current PICTURE number + state.currentPicture = null; // Will be set via load.pic script entry later on. + // [317] 348 - 349(2 bytes) Blocking flag (1 = true, 0 = false) + state.blocking = ((savedGameData[348] & 0xFF) + ((savedGameData[349] & 0xFF) << 8)) == 1; + + // [319] 350 - 351(2 bytes) Max drawn. Always set to 15. Maximum number of animated objects that can be drawn at a time. Set by old max.drawn command in AGI v2.001. + state.maxDrawn = ((savedGameData[350] & 0xFF) + ((savedGameData[351] & 0xFF) << 8)); + // [321] 352 - 353(2 bytes) Script size. Set by script.size. Max number of script event items. Default is 50. + state.scriptBuffer.setScriptSize((savedGameData[352] & 0xFF) + ((savedGameData[353] & 0xFF) << 8)); + // [323] 354 - 355(2 bytes) Current number of script event entries. + int scriptEntryCount = ((savedGameData[354] & 0xFF) + ((savedGameData[355] & 0xFF) << 8)); + + // [325] 356 - 555(200 or 160 bytes) ? Key to controller map (4 bytes each) + int keyMapSize = getNumberOfControllers(state.version); + for (int i = 0; i < keyMapSize; i++) { + int keyMapOffset = i << 2; + int keyCode = ((savedGameData[356 + keyMapOffset] & 0xFF) + ((savedGameData[357 + keyMapOffset] & 0xFF) << 8)); + int controllerNum = ((savedGameData[358 + keyMapOffset] & 0xFF) + ((savedGameData[359 + keyMapOffset] & 0xFF) << 8)); + if (!((keyCode == 0) && (controllerNum == 0)) && userInput.keyCodeMap.containsKey(keyCode)) { + int interKeyCode = userInput.keyCodeMap.get(keyCode); + if (state.keyToControllerMap.containsKey(interKeyCode)) { + state.keyToControllerMap.remove(interKeyCode); + } + state.keyToControllerMap.put(userInput.keyCodeMap.get(keyCode), controllerNum); + } + } + + int postKeyMapOffset = 356 + (keyMapSize << 2); + + // [525] 556 - 1515(480 or 960 bytes) 12 or 24 strings, each 40 bytes long + int numOfStrings = getNumberOfStrings(state.version); + for (int i = 0; i < numOfStrings; i++) { + int stringOffset = postKeyMapOffset + (i * Defines.STRLENGTH); + textEnd = stringOffset; + while (((savedGameData[textEnd] & 0xFF) != 0) && ((textEnd - stringOffset) < Defines.STRLENGTH)) textEnd++; + state.strings[i] = new String(savedGameData, stringOffset, textEnd - stringOffset, Charset.forName("Cp437")); + } + + int postStringsOffset = postKeyMapOffset + (numOfStrings * Defines.STRLENGTH); + + // [1485] 1516(2 bytes) Foreground colour + state.foregroundColour = ((savedGameData[postStringsOffset + 0] & 0xFF) + ((savedGameData[postStringsOffset + 1] & 0xFF) << 8)); + + // [1487] 1518(2 bytes) Background colour + int backgroundColour = ((savedGameData[postStringsOffset + 2] & 0xFF) + ((savedGameData[postStringsOffset + 3] & 0xFF) << 8)); + // TODO: Interpreter doesn't yet properly handle AGI background colour. + + // [1489] 1520(2 bytes) Text Attribute value (combined foreground/background value) + int textAttribute = ((savedGameData[postStringsOffset + 4] & 0xFF) + ((savedGameData[postStringsOffset + 5] & 0xFF) << 8)); + + // [1491] 1522(2 bytes) Accept input = 1, Prevent input = 0 + state.acceptInput = ((savedGameData[postStringsOffset + 6] & 0xFF) + ((savedGameData[postStringsOffset + 7] & 0xFF) << 8)) == 1; + + // [1493] 1524(2 bytes) User input row on the screen + state.inputLineRow = ((savedGameData[postStringsOffset + 8] & 0xFF) + ((savedGameData[postStringsOffset + 9] & 0xFF) << 8)); + + // [1495] 1526(2 bytes) Cursor character + state.cursorCharacter = (char)((savedGameData[postStringsOffset + 10] & 0xFF) + ((savedGameData[postStringsOffset + 11] & 0xFF) << 8)); + + // [1497] 1528(2 bytes) Show status line = 1, Don't show status line = 0 + state.showStatusLine = ((savedGameData[postStringsOffset + 12] & 0xFF) + ((savedGameData[postStringsOffset + 13] & 0xFF) << 8)) == 1; + + // [1499] 1530(2 bytes) Status line row on the screen + state.statusLineRow = ((savedGameData[postStringsOffset + 14] & 0xFF) + ((savedGameData[postStringsOffset + 15] & 0xFF) << 8)); + + // [1501] 1532(2 bytes) Picture top row on the screen + state.pictureRow = ((savedGameData[postStringsOffset + 16] & 0xFF) + ((savedGameData[postStringsOffset + 17] & 0xFF) << 8)); + + // [1503] 1534(2 bytes) Picture bottom row on the screen + // Note: Not needed by this intepreter. + int picBottom = ((savedGameData[postStringsOffset + 18] & 0xFF) + ((savedGameData[postStringsOffset + 19] & 0xFF) << 8)); + + if ((postStringsOffset + 20) < aniObjsOffset) { + // [1505] 1536(2 bytes) Stores a pushed position within the script event list + // Note: Depends on interpreter version. 2.4xx and below didn't have push.script/pop.script, so they didn't have this saved game field. + state.scriptBuffer.savedScript = ((savedGameData[postStringsOffset + 20] & 0xFF) + ((savedGameData[postStringsOffset + 21] & 0xFF) << 8)); + } + + // SECOND PIECE: ANIMATED OBJECT STATE + // 17 aniobjs = 0x02DB length, 18 aniobjs = 0x0306, 20 aniobjs = 0x035C, 21 aniobjs = 0x0387, 91 = 0x0F49] 2B, 2B, 2B, 2B, 2B + // 1538 - 1539(2 bytes) Length of piece (ANIOBJ should divide evenly in to this length) + int aniObjectsLength = ((savedGameData[aniObjsOffset + 0] & 0xFF) + ((savedGameData[aniObjsOffset + 1] & 0xFF) << 8)); + // Each ANIOBJ entry is 0x2B in length, i.e. 43 bytes. + // 17 aniobjs = 0x02DB length, 18 aniobjs = 0x0306, 20 aniobjs = 0x035C, 21 aniobjs = 0x0387, 91 = 0x0F49] 2B, 2B, 2B, 2B, 2B + int numOfAniObjs = (aniObjectsLength / 0x2B); + + for (int i = 0; i < numOfAniObjs; i++) { + int aniObjOffset = aniObjsOffset + 2 + (i * 0x2B); + AnimatedObject aniObj = state.animatedObjects[i]; + aniObj.reset(); + + // Each ANIOBJ entry is 0x2B in length, i.e. 43 bytes. + // Example: KQ1 - ego - starting position in room 1 + // 01 01 00 6e 00 64 00 00 17 6b 00 04 24 6b 00 06 + // 31 6b 31 6b 2f 9c 6e 00 64 00 06 00 20 00 01 01 + // 01 00 00 00 09 53 40 00 00 00 00 + + //UBYTE movefreq; /* number of animation cycles between motion */ e.g. 01 + aniObj.stepTime = (savedGameData[aniObjOffset + 0] & 0xFF); + //UBYTE moveclk; /* number of cycles between moves of object */ e.g. 01 + aniObj.stepTimeCount = (savedGameData[aniObjOffset + 1] & 0xFF); + //UBYTE num; /* object number */ e.g. 00 + aniObj.objectNumber = savedGameData[aniObjOffset + 2]; + //COORD x; /* current x coordinate */ e.g. 6e 00 (0x006e = ) + aniObj.x = (short)((savedGameData[aniObjOffset + 3] & 0xFF) + ((savedGameData[aniObjOffset + 4] & 0xFF) << 8)); + //COORD y; /* current y coordinate */ e.g. 64 00 (0x0064 = ) + aniObj.y = (short)((savedGameData[aniObjOffset + 5] & 0xFF) + ((savedGameData[aniObjOffset + 6] & 0xFF) << 8)); + //UBYTE view; /* current view number */ e.g. 00 + aniObj.currentView = (savedGameData[aniObjOffset + 7] & 0xFF); + //VIEW* viewptr; /* pointer to current view */ e.g. 17 6b (0x6b17 = ) IGNORE. + //UBYTE loop; /* current loop in view */ e.g. 00 + aniObj.currentLoop = (savedGameData[aniObjOffset + 10] & 0xFF); + //UBYTE loopcnt; /* number of loops in view */ e.g. 04 IGNORE + //LOOP* loopptr; /* pointer to current loop */ e.g. 24 6b (0x6b24 = ) IGNORE + //UBYTE cel; /* current cell in loop */ e.g. 00 + aniObj.currentCel = (savedGameData[aniObjOffset + 14] & 0xFF); + //UBYTE celcnt; /* number of cells in current loop */ e.g. 06 IGNORE + //CEL* celptr; /* pointer to current cell */ e.g. 31 6b (0x6b31 = ) IGNORE + //CEL* prevcel; /* pointer to previous cell */ e.g. 31 6b (0x6b31 = ) + if (aniObj.view() != null) aniObj.previousCel = aniObj.cel(); + //STRPTR save; /* pointer to background save area */ e.g. 2f 9c (0x9c2f = ) IGNORE + //COORD prevx; /* previous x coordinate */ e.g. 6e 00 (0x006e = ) + aniObj.prevX = (short)((savedGameData[aniObjOffset + 22] & 0xFF) + ((savedGameData[aniObjOffset + 23] & 0xFF) << 8)); + //COORD prevy; /* previous y coordinate */ e.g. 64 00 (0x0064 = ) + aniObj.prevY = (short)((savedGameData[aniObjOffset + 24] & 0xFF) + ((savedGameData[aniObjOffset + 25] & 0xFF) << 8)); + //COORD xsize; /* x dimension of current cell */ e.g. 06 00 (0x0006 = ) IGNORE + //COORD ysize; /* y dimension of current cell */ e.g. 20 00 (0x0020 = ) IGNORE + //UBYTE stepsize; /* distance object can move */ e.g. 01 + aniObj.stepSize = (savedGameData[aniObjOffset + 30] & 0xFF); + //UBYTE cyclfreq; /* time interval between cells of object */ e.g. 01 + aniObj.cycleTime = (savedGameData[aniObjOffset + 31] & 0xFF); + //UBYTE cycleclk; /* counter for determining when object cycles */ e.g. 01 + aniObj.cycleTimeCount = (savedGameData[aniObjOffset + 32] & 0xFF); + //UBYTE dir; /* object direction */ e.g. 00 + aniObj.direction = savedGameData[aniObjOffset + 33]; + //UBYTE motion; /* object motion type */ e.g. 00 + // #define WANDER 1 /* random movement */ + // #define FOLLOW 2 /* follow an object */ + // #define MOVETO 3 /* move to a given coordinate */ + aniObj.motionType = MotionType.values()[savedGameData[aniObjOffset + 34]]; + //UBYTE cycle; /* cell cycling type */ e.g. 00 + // #define NORMAL 0 /* normal repetative cycling of object */ + // #define ENDLOOP 1 /* animate to end of loop and stop */ + // #define RVRSLOOP 2 /* reverse of ENDLOOP */ + // #define REVERSE 3 /* cycle continually in reverse */ + aniObj.cycleType = CycleType.values()[savedGameData[aniObjOffset + 35]]; + //UBYTE pri; /* priority of object */ e.g. 09 + aniObj.priority = savedGameData[aniObjOffset + 36]; + //UWORD control; /* object control flag (bit mapped) */ e.g. 53 40 (0x4053 = ) + int controlBits = ((savedGameData[aniObjOffset + 37] & 0xFF) + ((savedGameData[aniObjOffset + 38] & 0xFF) << 8)); + /* object control bits */ + // DRAWN 0x0001 /* 1 -> object is drawn on screen */ + aniObj.drawn = ((controlBits & 0x0001) > 0); + // IGNRBLK 0x0002 /* 1 -> object ignores blocks */ + aniObj.ignoreBlocks = ((controlBits & 0x0002) > 0); + // FIXEDPRI 0x0004 /* 1 -> object has fixed priority */ + aniObj.fixedPriority = ((controlBits & 0x0004) > 0); + // IGNRHRZ 0x0008 /* 1 -> object ignores the horizon */ + aniObj.ignoreHorizon = ((controlBits & 0x0008) > 0); + // UPDATE 0x0010 /* 1 -> update the object */ + aniObj.update = ((controlBits & 0x0010) > 0); + // CYCLE 0x0020 /* 1 -> cycle the object */ + aniObj.cycle = ((controlBits & 0x0020) > 0); + // ANIMATED 0x0040 /* 1 -> object can move */ + aniObj.animated = ((controlBits & 0x0040) > 0); + // BLOCKED 0x0080 /* 1 -> object is blocked */ + aniObj.blocked = ((controlBits & 0x0080) > 0); + // PRICTRL1 0x0100 /* 1 -> object must be on 'water' priority */ + aniObj.stayOnWater = ((controlBits & 0x0100) > 0); + // IGNROBJ 0x0200 /* 1 -> object won't collide with objects */ + aniObj.ignoreObjects = ((controlBits & 0x0200) > 0); + // REPOS 0x0400 /* 1 -> object being reposn'd in this cycle */ + aniObj.repositioned = ((controlBits & 0x0400) > 0); + // PRICTRL2 0x0800 /* 1 -> object must not be entirely on water */ + aniObj.stayOnLand = ((controlBits & 0x0800) > 0); + // NOADVANC 0x1000 /* 1 -> don't advance object's cel in this loop */ + aniObj.noAdvance = ((controlBits & 0x1000) > 0); + // FIXEDLOOP 0x2000 /* 1 -> object's loop is fixed */ + aniObj.fixedLoop = ((controlBits & 0x2000) > 0); + // STOPPED 0x4000 /* 1 -> object did not move during last animation cycle */ + aniObj.stopped = ((controlBits & 0x4000) > 0); + //UBYTE parms[4]; /* space for various motion parameters */ e.g. 00 00 00 00 + aniObj.motionParam1 = (short)(savedGameData[aniObjOffset + 39] & 0xFF); + aniObj.motionParam2 = (short)(savedGameData[aniObjOffset + 40] & 0xFF); + aniObj.motionParam3 = (short)(savedGameData[aniObjOffset + 41] & 0xFF); + aniObj.motionParam4 = (short)(savedGameData[aniObjOffset + 42] & 0xFF); + // If motion type is follow, then force a re-initialisation of the follow path. + if (aniObj.motionType == MotionType.FOLLOW) aniObj.motionParam3 = -1; + } + + // THIRD PIECE: OBJECTS + // Almost an exact copy of the OBJECT file, but with the 3 byte header removed, and room + // numbers reflecting the current location of each object. + int objectsOffset = aniObjsOffset + 2 + aniObjectsLength; + int objectsLength = (savedGameData[objectsOffset + 0] + (savedGameData[objectsOffset + 1] << 8)); + // The NumOfAnimatedObjects, as stored in OBJECT, should be 1 less than the number of animated object slots + // (due to add.to.pic slot), otherwise this number increments by 1 on every save followed by restore. + state.objects.numOfAnimatedObjects = (numOfAniObjs - 1); + if (state.isAGIV3()) { + // AGI V3 games xor encrypt the data with Avis Durgan. + crypt(savedGameData, objectsOffset + 2, objectsOffset + objectsLength); + } + int numOfObjects = ((savedGameData[objectsOffset + 2] & 0xFF) + ((savedGameData[objectsOffset + 3] & 0xFF) << 8)) / 3; + // Set the saved room number of each Object. + for (int objectNum = 0, roomPos = objectsOffset + 4; objectNum < numOfObjects; objectNum++, roomPos += 3) { + state.objects.objects.get(objectNum).room = (savedGameData[roomPos] & 0xFF); + } + + // FOURTH PIECE: SCRIPT BUFFER EVENTS + // A transcript of events leading to the current state in the current room. + int scriptsOffset = objectsOffset + 2 + objectsLength; + int scriptsLength = ((savedGameData[scriptsOffset + 0] & 0xFF) + ((savedGameData[scriptsOffset + 1] & 0xFF) << 8)); + // Each script entry is two unsigned bytes long: + // UBYTE action; + // UBYTE who; + // + // Action byte is a code defined as follows: + // S_LOADLOG 0 + // S_LOADVIEW 1 + // S_LOADPIC 2 + // S_LOADSND 3 + // S_DRAWPIC 4 + // S_ADDPIC 5 + // S_DSCRDPIC 6 + // S_DSCRDVIEW 7 + // S_OVERLAYPIC 8 + // + // Example: + // c8 00 Length + // 00 01 load.logic 0x01 + // 01 00 load.view 0x00 + // 00 66 load.logic 0x66 + // 01 4b load.view 0x4B + // 01 57 load.view 0x57 + // 01 6e load.view 0x6e + // 02 01 load.pic 0x01 + // 04 01 draw.pic 0x01 + // 06 01 discard.pic 0x01 + // 00 65 load.logic 0x65 + // 01 6b load.view 0x6B + // 01 61 load.view 0x61 + // 01 5d load.view 0x5D + // 01 46 load.view 0x46 + // 03 0d load.sound 0x0D + // etc... + state.scriptBuffer.initScript(); + for (int i = 0; i < scriptEntryCount; i++) { + int scriptOffset = scriptsOffset + 2 + (i * 2); + int action = (savedGameData[scriptOffset + 0] & 0xFF); + ScriptBufferEventType eventType = ScriptBufferEventType.values()[action]; + int resourceNum = (savedGameData[scriptOffset + 1] & 0xFF); + byte[] data = null; + if (eventType == ScriptBufferEventType.ADD_TO_PIC) { + // The add.to.pics are stored in the saved game file across 8 bytes, i.e. 4 separate script + // entries (that is also how the original AGI interpreter stored it in memory). + // What we do though is store these in an additional data array associated with + // the script event since utilitising multiple event entries is a bit of a hack + // really. I can understand why they did it though. + data = new byte[] { + savedGameData[scriptOffset + 2], savedGameData[scriptOffset + 3], savedGameData[scriptOffset + 4], + savedGameData[scriptOffset + 5], savedGameData[scriptOffset + 6], savedGameData[scriptOffset + 7] + }; + + // Increase i to account for the fact that we've processed an additional 3 slots. + i += 3; + } + state.scriptBuffer.restoreScript(eventType, resourceNum, data); + } + + // FIFTH PIECE: SCAN OFFSETS + // Note: Not every logic can set a scan offset, as there is a max of 30. But only + // loaded logics can have this set and I'd imagine you'd run out of memory before + // loading that many logics at once. + int scanOffsetsOffset = scriptsOffset + 2 + scriptsLength; + int scanOffsetsLength = ((savedGameData[scanOffsetsOffset + 0] & 0xFF) + ((savedGameData[scanOffsetsOffset + 1] & 0xFF) << 8)); + int numOfScanOffsets = (scanOffsetsLength / 4); + // Each entry is 4 bytes long, made up of 2 16-bit words: + // COUNT num; /* logic number */ + // COUNT ofs; /* offset to scan start */ + // + // Example: + // 18 00 + // 00 00 00 00 Start of list. Seems to always be 4 zeroes. + // 00 00 00 00 Logic 0 - Offset 0 + // 01 00 00 00 Logic 1 - Offset 0 + // 66 00 00 00 Logic 102 - Offset 0 + // 65 00 00 00 Logic 101 - Offset 0 + // ff ff 00 00 End of list + // + // Quick Analysis of the above: + // * Only logics that are current loaded are in the scan offset list, i.e. they're removed when the room changes. + // * The order logics appear in this list is the order that they are loaded. + // * Logics disappear from this list when they are unloaded (on new.room). + // * The new.room command unloads all logics except for logic 0, so it never leaves this list. + for (int i = 0; i < 256; i++) state.scanStart[i] = 0; + for (int i = 1; i < numOfScanOffsets; i++) { + int scanOffsetOffset = scanOffsetsOffset + 2 + (i * 4); + int logicNumber = ((savedGameData[scanOffsetOffset + 0] & 0xFF) + ((savedGameData[scanOffsetOffset + 1] & 0xFF) << 8)); + if (logicNumber < 256) { + state.scanStart[logicNumber] = ((savedGameData[scanOffsetOffset + 2] & 0xFF) + ((savedGameData[scanOffsetOffset + 3] & 0xFF) << 8)); + } + } + + state.flags[Defines.RESTORE] = true; + + // Return true to say that we have successfully restored a saved game file. + return true; + } +} diff --git a/core/src/main/java/com/agifans/agile/ScriptBuffer.java b/core/src/main/java/com/agifans/agile/ScriptBuffer.java new file mode 100644 index 0000000..2f7e794 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/ScriptBuffer.java @@ -0,0 +1,213 @@ +package com.agifans.agile; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +public class ScriptBuffer { + + public enum ScriptBufferEventType { + LOAD_LOGIC, + LOAD_VIEW, + LOAD_PIC, + LOAD_SOUND, + DRAW_PIC, + ADD_TO_PIC, + DISCARD_PIC, + DISCARD_VIEW, + OVERLAY_PIC + } + + public class ScriptBufferEvent { + public ScriptBufferEventType type; + public int resourceNumber; + public byte[] data; + + public ScriptBufferEvent(ScriptBufferEventType type, int resourceNumber, byte[] data) { + this.type = type; + this.resourceNumber = resourceNumber; + this.data = data; + } + } + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * A transcript of events leading to the current state in the current room. + */ + public List events; + + /** + * Whether or not the storage of script events in the buffer is enabled or not. + */ + private boolean doScript; + + public int maxScript; + public int scriptSize; + public int scriptEntries() { + int count = 0; + for (ScriptBufferEvent e : events) + { + // in AGI, the add.to.pic script event consist of 4 entries + // (who, action, loop #, view #, X, Y, cel #, priority) + // the rest of the events are just 1 entry (who, action) + if (e.type == ScriptBufferEventType.ADD_TO_PIC) + { + count += 4; + } + else + { + count += 1; + } + } + return count; + } + public int savedScript; + + /** + * Constructor for ScriptBuffer. + * + * @param state + */ + public ScriptBuffer(GameState state) { + // Default script size is 50 according to original AGI specs. + this.scriptSize = 50; + this.events = new ArrayList(); + this.state = state; + initScript(); + } + + /** + * + */ + public void scriptOff() { + doScript = false; + } + + /** + * + */ + public void scriptOn() { + doScript = true; + } + + /** + * Initialize the script buffer. + */ + public void initScript() { + events.clear(); + } + + /** + * Add an event to the script buffer + * + * @param action + * @param who + */ + public void addScript(ScriptBufferEventType action, int who) { + addScript(action, who, null); + } + + /** + * Add an event to the script buffer + * + * @param action + * @param who + * @param data + */ + public void addScript(ScriptBufferEventType action, int who, byte[] data) { + if (state.flags[Defines.NO_SCRIPT]) return; + + if (doScript) { + if (events.size() >= this.scriptSize) { + // TODO: Error. Error(11, maxScript); + return; + } + else { + events.add(new ScriptBufferEvent(action, who, data)); + } + } + + if (events.size() > maxScript) { + maxScript = events.size(); + } + } + + /** + * + * @param scriptSize + */ + public void setScriptSize(int scriptSize) { + this.scriptSize = scriptSize; + this.events.clear(); + } + + /** + * + */ + public void pushScript() { + this.savedScript = events.size(); + } + + /** + * + */ + public void popScript() { + if (events.size() > this.savedScript) { + events = events.subList(0, this.savedScript); + } + } + + /** + * Returns the script event buffer as a raw byte array. + * + * @return + */ + public byte[] encode() { + // Each script entry is two bytes long. + ByteArrayOutputStream stream = new ByteArrayOutputStream(this.scriptSize * 2); + + for (ScriptBufferEvent e : events) { + stream.write((byte)(e.type.ordinal())); + stream.write((byte)e.resourceNumber); + if (e.data != null) { + stream.write(e.data, 0, e.data.length); + } + } + + // If we didn't write exactly the expected size, then fill the rest with 0. + while (stream.size() < (this.scriptSize * 2)) { + stream.write(0); + } + + return stream.toByteArray(); + } + + /** + * Add an event to the script buffer without checking NO_SCRIPT flag. Used primarily by restore save game function. + * + * @param action + * @param who + */ + public void restoreScript(ScriptBufferEventType action, int who) { + restoreScript(action, who, null); + } + + /** + * Add an event to the script buffer without checking NO_SCRIPT flag. Used primarily by restore save game function. + * + * @param action + * @param who + */ + public void restoreScript(ScriptBufferEventType action, int who, byte[] data) { + events.add(new ScriptBufferEvent(action, who, data)); + + if (events.size() > maxScript) { + maxScript = events.size(); + } + } +} diff --git a/core/src/main/java/com/agifans/agile/SoundPlayer.java b/core/src/main/java/com/agifans/agile/SoundPlayer.java new file mode 100644 index 0000000..0775361 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/SoundPlayer.java @@ -0,0 +1,441 @@ +package com.agifans.agile; + +import java.io.ByteArrayOutputStream; +import java.util.HashMap; +import java.util.Map; + +import com.agifans.agile.agilib.Sound; +import com.agifans.agile.agilib.Sound.Note; + +/** + * A class for playing AGI sounds. + */ +public class SoundPlayer { + + private static final int SAMPLE_RATE = 44100; + + /** + * The GameState class holds all of the data and state for the Game currently + * being run by the interpreter. + */ + private GameState state; + + /** + * A cache of the generated WAVE data for loaded sounds. + */ + public Map soundCache; + + /** + * The number of the Sound resource currently playing, or -1 if none should be playing. + */ + private int soundNumPlaying; + + /** + * The WavePlayer that will play the generated WAV file data. + */ + private WavePlayer wavePlayer; + + private static final short[] dissolveDataV2 = new short[] { + -2, -3, -2, -1, 0x00, 0x00, 0x01, 0x01, + 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, + 0x05, 0x06, 0x06, 0x06, 0x06, 0x06, 0x07, 0x07, + 0x07, 0x07, 0x08, 0x08, 0x08, 0x08, 0x09, 0x09, + 0x09, 0x09, 0x0A, 0x0A, 0x0A, 0x0A, 0x0B, 0x0B, + 0x0B, 0x0B, 0x0B, 0x0B, 0x0C, 0x0C, 0x0C, 0x0C, + 0x0C, 0x0C, 0x0D, -100 + }; + + private static final short[] dissolveDataV3 = new short[] { + -2, -3, -2, -1, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x04, 0x04, 0x04, 0x04, 0x04, 0x05, 0x05, + 0x05, 0x05, 0x05, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x07, 0x07, 0x07, 0x07, 0x08, 0x08, 0x08, 0x08, + 0x09, 0x09, 0x09, 0x09, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0B, 0x0B, 0x0B, 0x0B, 0x0B, 0x0B, 0x0C, 0x0C, + 0x0C, 0x0C, 0x0C, 0x0C, 0x0D, -100 + }; + + private short[] dissolveData; + + /** + * Constructor for SoundPlayer. + * + * @param state + * @param wavePlayer The WavePlayer that will play the generated WAV file data. + */ + public SoundPlayer(GameState state, WavePlayer wavePlayer) { + this.state = state; + this.wavePlayer = wavePlayer; + this.soundCache = new HashMap(); + this.soundNumPlaying = -1; + this.dissolveData = (state.isAGIV3()? dissolveDataV3 : dissolveDataV2); + } + + /** + * Loads and generates an AGI Sound, caching it in a ready to play state. + * + * @param sound The AGI sound to load. + */ + public void loadSound(Sound sound) { + Note[] voiceCurrentNote = new Note[4]; + boolean[] voicePlaying = new boolean[] { true, true, true, true }; + int[] voiceSampleCount = new int[4]; + int[] voiceNoteNum = new int[4]; + int[] voiceDissolveCount = new int[4]; + int durationUnitCount = 0; + + // A single note duration unit is 1/60th of a second + int samplesPerDurationUnit = SAMPLE_RATE / 60; + ByteArrayOutputStream sampleStream = new ByteArrayOutputStream(); + + // Create a new PSG for each sound, to guarantee a clean state. + SN76496 psg = new SN76496(); + + // Start by converting the Notes into samples. + while (voicePlaying[0] || voicePlaying[1] || voicePlaying[2] || voicePlaying[3]) { + for (int voiceNum = 0; voiceNum < 4; voiceNum++) { + if (voicePlaying[voiceNum]) { + if (voiceSampleCount[voiceNum]-- <= 0) { + if (voiceNoteNum[voiceNum] < sound.notes.get(voiceNum).size()) { + voiceCurrentNote[voiceNum] = sound.notes.get(voiceNum).get(voiceNoteNum[voiceNum]++); + byte[] psgBytes = voiceCurrentNote[voiceNum].rawData; + psg.write(psgBytes[3] & 0xFF); + psg.write(psgBytes[2] & 0xFF); + psg.write(psgBytes[4] & 0xFF); + voiceSampleCount[voiceNum] = voiceCurrentNote[voiceNum].duration * samplesPerDurationUnit; + voiceDissolveCount[voiceNum] = 0; + } + else { + voicePlaying[voiceNum] = false; + psg.setVolByNumber(voiceNum, 0x0F); + } + } + if ((durationUnitCount == 0) && (voicePlaying[voiceNum])) { + voiceDissolveCount[voiceNum] = updateVolume(psg, voiceCurrentNote[voiceNum].origVolume, voiceNum, voiceDissolveCount[voiceNum]); + } + } + } + + // This count hits zero 60 times a second. It counts samples from 0 to 734 (i.e. (44100 / 60) - 1). + durationUnitCount = ((durationUnitCount + 1) % samplesPerDurationUnit); + + // Use the SN76496 PSG emulation to generate the sample data. + short sample = (short)(psg.render()); + sampleStream.write(sample & 0xFF); + sampleStream.write((sample >> 8) & 0xFF); + sampleStream.write(sample & 0xFF); + sampleStream.write((sample >> 8) & 0xFF); + } + + // Use the samples to create a Wave file. These can be several MB in size (e.g. 5MB, 8MB, 10MB) + byte[] waveData = createWave(sampleStream.toByteArray()); + + // Cache for use when the sound is played. This reduces overhead of generating WAV on every play. + this.soundCache.put(sound.index, waveData); + } + + /** + * Creates a WAVE file from the given sample data by pre-pending the + * standard WAV file format header to the start. + * + * @param sampleData The sample data to create the WAVE file from. + * + * @return byte array containing the WAV file data. + */ + private byte[] createWave(byte[] sampleData) { + // Create WAVE header + int headerLen = 44; + int l1 = (sampleData.length + headerLen) - 8; // Total size of file minus 8. + int l2 = sampleData.length; + byte[] wave = new byte[headerLen + sampleData.length]; + byte[] header = new byte[] { + 82, 73, 70, 70, // RIFF + (byte)(l1 & 255), (byte)((l1 >> 8) & 255), (byte)((l1 >> 16) & 255), (byte)((l1 >> 24) & 255), + 87, 65, 86, 69, // WAVE + 102, 109, 116, 32, // fmt (chunk ID) + 16, 0, 0, 0, // size (chunk size) + 1, 0, // audio format (PCM = 1, i.e. Linear quantization) + 2, 0, // number of channels + 68, (byte)172, 0, 0, // sample rate (samples per second), i.e. 44100 + 16, (byte)177, 2, 0, // byte rate (average bytes per second, == SampleRate * NumChannels * BitsPerSample/8) + 4, 0, // block align (== NumChannels * BitsPerSample/8) + 16, 0, // bits per sample (i.e 16 bits per sample) + 100, 97, 116, 97, // data (chunk ID) + (byte)(l2 & 255), (byte)((l2 >> 8) & 255), (byte)((l2 >> 16) & 255), (byte)((l2 >> 24) & 255) + }; + + System.arraycopy(header, 0, wave, 0, headerLen); + System.arraycopy(sampleData, 0, wave, headerLen, sampleData.length); + + // Return the WAVE formatted typed array + return wave; + } + + /** + * Updates the volume of the given channel, by applying the dissolve data and master volume to the + * given base volume and then sets that in the SN76496 PSG. The noise channel does not apply the + * dissolve data, so skips that bit. + * + * @param psg The SN76496 PSG to set the calculated volume in. + * @param baseVolume The base volume to apply the dissolve data and master volume to. + * @param channel The channel to update the volume for. + * @param dissolveCount The current dissolve count value for the note being played by the given channel. + * + * @return The new dissolve count value for the channel. + */ + private int updateVolume(SN76496 psg, int baseVolume, int channel, int dissolveCount) { + int volume = baseVolume; + + if (volume != 0x0F) { + int dissolveValue = (dissolveData[dissolveCount] == -100 ? dissolveData[dissolveCount - 1] : dissolveData[dissolveCount++]); + + // Add master volume and dissolve value to current channel volume. Noise channel doesn't dissolve. + if (channel < 3) volume += dissolveValue; + + volume += this.state.vars[Defines.ATTENUATION]; + + if (volume < 0) volume = 0; + if (volume > 0x0F) volume = 0x0F; + if (volume < 8) volume += 2; + + // Apply calculated volume to PSG channel. + psg.setVolByNumber(channel, volume); + } + + return dissolveCount; + } + + /** + * Plays the given AGI Sound. + * + * @param sound The AGI Sound to play. + * @param endFlag The flag to set when the sound ends. + */ + public void playSound(Sound sound, int endFlag) { + // Stop any currently playing sound. Will set the end flag for the previous sound. + stopSound(); + + // Set the starting state of the sound end flag to false. + state.flags[endFlag] = false; + + // Get WAV data from the cache. + byte[] waveData = this.soundCache.get(sound.index); + if (waveData != null) { + // Now play the Wave file. + if (this.state.flags[Defines.SOUNDON]) { + soundNumPlaying = sound.index; + wavePlayer.playWaveData(waveData, () -> { + // This is run when the WAV data finishes playing. + soundNumPlaying = -1; + state.flags[endFlag] = true; + }); + } + else { + // If sound is not on, then it ends immediately. + soundNumPlaying = -1; + state.flags[endFlag] = true; + } + } + } + + /** + * Resets the internal state of the SoundPlayer. + */ + public void reset() { + stopSound(); + soundCache.clear(); + wavePlayer.reset(); + } + + /** + * Fully shuts down the SoundPlayer. Only intended for when AGILE is closing down. + */ + public void shutdown() { + reset(); + wavePlayer.dispose(); + } + + /** + * Stops the currently playing sound. This version of the method will always wait + * for the WAV player to finish playing before returning. + */ + public void stopSound() { + stopSound(true); + } + + /** + * Stops the currently playing sound. + * + * @param wait true to wait for the WAV player to finish playing; otherwise false to not wait. + */ + public void stopSound(boolean wait) { + if (soundNumPlaying >= 0) { + // Store that we're now not playing a sound. + soundNumPlaying = -1; + + // Ask WAV player to stop playing. The wait parameter tells the WAV + // player whether or not to wait until it has finished playing. + wavePlayer.stopPlaying(wait); + } + } + + /** + * SN76496 is the audio chip used in the IBM PC JR and therefore what the original AGI sound format was designed for. + */ + public static class SN76496 { + + private static final float IBM_PCJR_CLOCK = 3579545f; + + private static float[] volumeTable = new float[] { + 8191.5f, + 6506.73973474395f, + 5168.4870873095f, + 4105.4752242578f, + 3261.09488758897f, + 2590.37974532693f, + 2057.61177037107f, + 1634.41912530676f, + 1298.26525860452f, + 1031.24875107119f, + 819.15f, + 650.673973474395f, + 516.84870873095f, + 410.54752242578f, + 326.109488758897f, + 0.0f + }; + + private int[] channelVolume = new int[] { 15, 15, 15, 15 }; + private int[] channelCounterReload = new int[4]; + private int[] channelCounter = new int[4]; + private int[] channelOutput = new int[4]; + private int lfsr; + private int latchedChannel; + private boolean updateVolume; + private float ticksPerSample; + private float ticksCount; + + public SN76496() { + ticksPerSample = IBM_PCJR_CLOCK / 16 / SAMPLE_RATE; + ticksCount = ticksPerSample; + latchedChannel = 0; + updateVolume = false; + lfsr = 0x4000; + } + + public void setVolByNumber(int channel, int volume) { + channelVolume[channel] = (int)(volume & 0x0F); + } + + public int getVolByNumber(int channel) { + return (channelVolume[channel] & 0x0F); + } + + public void write(int data) { + /* + * A tone is produced on a voice by passing the sound chip a 3-bit register address + * and then a 10-bit frequency divisor. The register address specifies which voice + * the tone will be produced on. + * + * The actual frequency produced is the 10-bit frequency divisor given by F0 to F9 + * divided into 1/32 of the system clock frequency (3.579 MHz) which turns out to be + * 111,860 Hz. Keeping all this in mind, the following is the formula for calculating + * the frequency: + * + * f = 111860 / (((Byte2 & 0x3F) << 4) + (Byte1 & 0x0F)) + */ + int counterReloadValue; + + if ((data & 0x80) != 0) { + // First Byte + // 7 6 5 4 3 2 1 0 + // 1 . . . . . . . Identifies first byte (command byte) + // . R0 R1 . . . . . Voice number (i.e. channel) + // . . . R2 . . . . 1 = Update attenuation, 0 = Frequency count + // . . . . A0 A1 A2 A3 4-bit attenuation value. + // . . . . F6 F7 F8 F9 4 of 10 - bits in frequency count. + latchedChannel = (data >> 5) & 0x03; + counterReloadValue = (int)((channelCounterReload[latchedChannel] & 0xfff0) | (data & 0x0F)); + updateVolume = ((data & 0x10) != 0) ? true : false; + } + else { + // Second Byte - Frequency count only + // 7 6 5 4 3 2 1 0 + // 0 . . . . . . . Identifies second byte (completing byte for frequency count) + // . X . . . . . . Unused, ignored. + // . . F0 F1 F2 F3 F4 F5 6 of 10 - bits in frequency count. + counterReloadValue = (int)((channelCounterReload[latchedChannel] & 0x000F) | ((data & 0x3F) << 4)); + } + + if (updateVolume) { + // Volume latched. Update attenuation for latched channel. + channelVolume[latchedChannel] = (data & 0x0F); + } + else { + // Data latched. Update counter reload register for channel. + channelCounterReload[latchedChannel] = counterReloadValue; + + // If it is for the noise control register, then set LFSR back to starting value. + if (latchedChannel == 3) lfsr = 0x4000; + } + } + + private void updateToneChannel(int channel) { + // If the tone counter reload register is 0, then skip update. + if (channelCounterReload[channel] == 0) return; + + // Note: For some reason SQ2 intro, in docking scene, is quite sensitive to how this is decremented and tested. + + // Decrement channel counter. If zero, then toggle output and reload from + // the tone counter reload register. + if (--channelCounter[channel] <= 0) { + channelCounter[channel] = channelCounterReload[channel]; + channelOutput[channel] ^= 1; + } + } + + public float render() { + while (ticksCount > 0) { + updateToneChannel(0); + updateToneChannel(1); + updateToneChannel(2); + + channelCounter[3] -= 1; + if (channelCounter[3] < 0) { + // Reload noise counter. + if ((channelCounterReload[3] & 0x03) < 3) { + channelCounter[3] = (0x20 << (channelCounterReload[3] & 3)); + } + else { + // In this mode, the counter reload value comes from tone register 2. + channelCounter[3] = channelCounterReload[2]; + } + + int feedback = ((channelCounterReload[3] & 0x04) == 0x04) ? + // White noise. Taps bit 0 and bit 1 of the LFSR as feedback, with XOR. + ((lfsr & 0x0001) ^ ((lfsr & 0x0002) >> 1)) : + // Periodic. Taps bit 0 for the feedback. + (lfsr & 0x0001); + + // LFSR is shifted every time the counter times out. SR is 15-bit. Feedback added to top bit. + lfsr = (lfsr >> 1) | (feedback << 14); + channelOutput[3] = (int)(lfsr & 1); + } + + ticksCount -= 1; + } + + ticksCount += ticksPerSample; + + return (float)((volumeTable[channelVolume[0] & 0x0F] * ((channelOutput[0] - 0.5) * 2)) + + (volumeTable[channelVolume[1] & 0x0F] * ((channelOutput[1] - 0.5) * 2)) + + (volumeTable[channelVolume[2] & 0x0F] * ((channelOutput[2] - 0.5) * 2)) + + (volumeTable[channelVolume[3] & 0x0F] * ((channelOutput[3] - 0.5) * 2))); + } + } +} diff --git a/core/src/main/java/com/agifans/agile/TextGraphics.java b/core/src/main/java/com/agifans/agile/TextGraphics.java new file mode 100644 index 0000000..7dd5978 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/TextGraphics.java @@ -0,0 +1,1341 @@ +package com.agifans.agile; + +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.List; + +/** + * Provides methods for drawing text on to the AGI screen. + */ +public class TextGraphics { + + private static final int WINTOP = 1; + private static final int WINBOT = 20; + private static final int WINWIDTH = 30; + private static final int VMARGIN = 5; + private static final int HMARGIN = 5; + private static final int CHARWIDTH = 4; /* in our coordinates */ + private static final int CHARHEIGHT = 8; + private static final int INVERSE = 0x8f; /* inverse video, i.e. black on white */ + private static final int UNASSIGNED = -1; + + /** + * Stores details about the currently displayed text window. + */ + public static class TextWindow { + + // Mandatory items required by OpenWindow. + public int position; + public int dimensions; + public int x() { return ((((position >> 8) & 0xFF) << 1)); } + public int y() { return ((position & 0xFF) - (((dimensions >> 8) & 0xFF) - 1) + 8); } + public int width() { return ((dimensions & 0xFF) << 1); } + public int height() { return ((dimensions >> 8) & 0xFF); } + public int backgroundColour; + public int borderColour; + + // Items set by OpenWindow. + public short[] backPixels; + + // Items always set by WindowNoWait. + public int top; + public int left; + public int bottom; + public int right; + public String[] textLines; + public int textColour; + + // Items optionally set by WindowNoWait. + public AnimatedObject aniObj; + + public TextWindow(int position, int dimensions, int backgroundColour, int borderColour) { + this(position, dimensions, backgroundColour, borderColour, 0, 0, 0, 0, null, 0, null); + } + + public TextWindow( + int position, int dimensions, int backgroundColour, int borderColour, int top, int left, + int bottom, int right, String[] textLines, int textColour, AnimatedObject aniObj) { + this.position = position; + this.dimensions = dimensions; + this.backgroundColour = backgroundColour; + this.borderColour = borderColour; + this.top = top; + this.left = left; + this.bottom = bottom; + this.right = right; + this.textLines = textLines; + this.textColour = textColour; + this.aniObj = aniObj; + } + } + + /** + * Stores details about the currently displayed text window. + */ + private TextWindow openWindow; + + private int winWidth = -1; + private int winULRow = -1; + private int winULCol = -1; + private int maxLength; + + private char escapeChar = '\\'; /* the escape character */ + + /** + * The GameState class holds all of the data and state for the Game currently + */ + private GameState state; + + /** + * Holds the data and state for the user input, i.e. keyboard and mouse input. + */ + private UserInput userInput; + + /** + * The pixels array for the AGI screen, in which the text will be drawn. + */ + private short[] pixels; + + /** + * Constructor for TextGraphics. + * + * @param pixels The GameScreen pixels. This is what TextGraphics draws windows (and indirectly menus) to. + * @param state The GameState class holds all of the data and state for the Game currently running. + * @param userInput Holds the data and state for the user input, i.e. keyboard and mouse input. + */ + public TextGraphics(short[] pixels, GameState state, UserInput userInput) { + this.state = state; + this.userInput = userInput; + this.pixels = pixels; + this.openWindow = null; + this.clearLines(0, 24, 0); + } + + /** + * Sets the text colour attributes used when drawing text characters. + * + * @param foregroundColour + * @param backgroundColour + */ + public void setTextAttribute(int foregroundColour, int backgroundColour) { + state.foregroundColour = (foregroundColour & 0xFF); + state.backgroundColour = makeBackgroundColour(backgroundColour); + state.textAttribute = makeTextAttribute(foregroundColour, backgroundColour); + } + + /** + * Return the requested text attribute in it's internal representation. + * + * @param foregroundColour + * @param backgroundColour + * + * @return + */ + private int makeTextAttribute(int foregroundColour, int backgroundColour) { + if (!state.graphicsMode) { + // For text mode, put background in high nibble, fore in low. + return (((backgroundColour << 4) | foregroundColour) & 0xFF); + } + else { + // In graphics mode, if back is not black, approximate with inverse text (black on white). + return ((backgroundColour == 0? foregroundColour : INVERSE) & 0xFF); + } + } + + /** + * Return the internal representation for the requested background color. + * + * @param backgroundColour + * + * @return The internal representation for the requested background color. + */ + public int makeBackgroundColour(int backgroundColour) { + if (state.graphicsMode && (backgroundColour != 0)) { + // In graphics if back is not black, approximate with inverse text (black on white). + return (0xff); /* mask off inverse */ + } + else { + // This is rather strange, but for clear.lines and clear.text.rect, in text mode the + // background colour is black regardless of the colour parameter value. + return (0); + } + } + + /** + * Clears the lines from the specified top line to the specified bottom line using the + * + * @param top + * @param bottom + * @param backgroundColour + */ + public void clearLines(int top, int bottom, int backgroundColour) { + int startPos = top * 8 * 320; + int endPos = ((bottom + 1) * 8 * 320) - 1; + short colour = EgaPalette.colours[backgroundColour & 0x0F]; + + for (int i=startPos; i <= endPos; i++) { + this.pixels[i] = colour; + } + } + + /** + * Clears a text rectangle as specified by the top, left, bottom and right values. The top and + * + * @param top + * @param left + * @param bottom + * @param right + * @param backgroundColour + */ + public void clearRect(int top, int left, int bottom, int right, int backgroundColour) { + short backgroundRGB565 = EgaPalette.colours[backgroundColour & 0x0F]; + int height = ((bottom - top) + 1) * 8; + int width = ((right - left) + 1) * 8; + int startY = (top * 8); + int startX = (left * 8); + int startScreenPos = ((startY * 320) + startX); + int screenYAdd = 320 - width; + + for (int y = 0, screenPos = startScreenPos; y < height; y++, screenPos += screenYAdd) { + for (int x = 0; x < width; x++, screenPos++) { + this.pixels[screenPos] = backgroundRGB565; + } + } + } + + public void textScreen() { + textScreen(UNASSIGNED); + } + + public void textScreen(int backgroundColour) { + state.graphicsMode = false; + + if (backgroundColour == UNASSIGNED) { + setTextAttribute((byte)state.foregroundColour, (byte)state.backgroundColour); + // Note that the original AGI interpreter uses the background from the TextAttribute + // value rather than the current BackgroundColour. + backgroundColour = ((state.textAttribute >> 4) & 0x0F); + } + + // Clear the whole screen to the background colour. + clearLines(0, 24, backgroundColour); + } + + public void graphicsScreen() { + state.graphicsMode = true; + + setTextAttribute((byte)state.foregroundColour, (byte)state.backgroundColour); + + // Clear whole screen to black. + clearLines(0, 24, 0); + + // Copy VisualPixels to game screen. + System.arraycopy(state.visualPixels, 0, this.pixels, (8 * state.pictureRow) * 320, state.visualPixels.length); + + updateStatusLine(); + updateInputLine(); + } + + /** + * Draws a character to the AGI screen. Depending on the usage, this may either be done + * to the VisualPixels or directly to the GameScreen pixels. Windows and menu text is + * drawn directly to the GameScreen pixels, but Display action commands are drawn to the + * VisualPixels array. + * + * @param pixels The pixel array to draw the character to. + * @param charNum The ASCII code number of the character to draw. + * @param x The X position of the character. + * @param y The Y position of the character. + * @param foregroundColour The foreground colour of the character. + * @param backgroundColour The background colour of the character. + */ + public void drawChar(short[] pixels, byte charNum, int x, int y, int foregroundColour, int backgroundColour) { + drawChar(pixels, charNum, x, y, foregroundColour, backgroundColour, false); + } + + /** + * Draws a character to the AGI screen. Depending on the usage, this may either be done + * to the VisualPixels or directly to the GameScreen pixels. Windows and menu text is + * drawn directly to the GameScreen pixels, but Display action commands are drawn to the + * VisualPixels array. + * + * @param pixels The pixel array to draw the character to. + * @param charNum The ASCII code number of the character to draw. + * @param x The X position of the character. + * @param y The Y position of the character. + * @param foregroundColour The foreground colour of the character. + * @param backgroundColour The background colour of the character. + * @param halfTone If true then character are only half drawn. + */ + public void drawChar(short[] pixels, byte charNum, int x, int y, int foregroundColour, int backgroundColour, boolean halfTone) { + for (int byteNum = 0; byteNum < 8; byteNum++) { + int fontByte = (IBM_BIOS_FONT[(charNum << 3) + byteNum] & 0xFF); + boolean halfToneState = ((byteNum % 2) == 0); + + for (int bytePos = 7; bytePos >= 0; bytePos--) { + if (!halfTone || halfToneState) { + if ((fontByte & (1 << bytePos)) != 0) { + pixels[((y + byteNum) * 320) + x + (7 - bytePos)] = EgaPalette.colours[foregroundColour]; + } + else { + pixels[((y + byteNum) * 320) + x + (7 - bytePos)] = EgaPalette.colours[backgroundColour]; + } + } + + halfToneState = !halfToneState; + } + } + } + + /** + * Draws the given string to the AGI screen, at the given x/y position, in the given colours. + * + * @param pixels The pixel array to draw the character to. + * @param text The text to draw to the screen. + * @param x The X position of the text. + * @param y The Y position of the text. + */ + public void drawString(short[] pixels, String text, int x, int y) { + drawString(pixels, text, x, y, UNASSIGNED, UNASSIGNED, false); + } + + /** + * Draws the given string to the AGI screen, at the given x/y position, in the given colours. + * + * @param pixels The pixel array to draw the character to. + * @param text The text to draw to the screen. + * @param x The X position of the text. + * @param y The Y position of the text. + * @param foregroundColour Optional foreground colour. Defaults to currently active foreground colour if not specified. + * @param backgroundColour Optional background colour. Defaults to currently active background colour if not specified. + */ + public void drawString(short[]pixels, String text, int x, int y, int foregroundColour, int backgroundColour) { + drawString(pixels, text, x, y, foregroundColour, backgroundColour, false); + } + + /** + * Draws the given string to the AGI screen, at the given x/y position, in the given colours. + * + * @param pixels The pixel array to draw the character to. + * @param text The text to draw to the screen. + * @param x The X position of the text. + * @param y The Y position of the text. + * @param foregroundColour Optional foreground colour. Defaults to currently active foreground colour if not specified. + * @param backgroundColour Optional background colour. Defaults to currently active background colour if not specified. + * @param halfTone If true then character are only half drawn. + */ + public void drawString(short[]pixels, String text, int x, int y, int foregroundColour, int backgroundColour, boolean halfTone) { + // This method is used as both a general text drawing method, for things like the menu + // and inventory, and also for the print and display commands. The print and display + // commands will operate using the currently set text attribute, foreground and background + // values. The more general use cases would pass in the exact colours that they want to + // use, no questions asked. + + // Foreground colour. + if (foregroundColour == UNASSIGNED) { + if (state.graphicsMode) { + // In graphics mode, if background is not black, foreground is black; otherwise as is. + foregroundColour = (state.backgroundColour == 0? state.foregroundColour : 0); + } + else { + // In text mode, we use the text attribute foreground colour as is. + foregroundColour = (state.textAttribute & 0x0F); + } + } + + // Background colour. + if (backgroundColour == UNASSIGNED) { + if (state.graphicsMode) { + // In graphics mode, background can only be black or white. + backgroundColour = (state.backgroundColour == 0 ? 0 : 15); + } + else { + // In text mode, we use the text attribute background colour as is. + backgroundColour = ((state.textAttribute >> 4) & 0x0F); + } + } + + try { + byte[] textBytes = text.getBytes("Cp437"); + + for (int charPos = 0; charPos < textBytes.length; charPos++) { + drawChar(pixels, textBytes[charPos], x + (charPos * 8), y, foregroundColour, backgroundColour, halfTone); + } + } catch (UnsupportedEncodingException e) { + // Shouldn't happen. + System.out.println("Unsupport encoding Cp437. AGI games need this to render text."); + } + } + + /** + * Display the given string at the given row and col. This method renders only the text and + * does not pop up a message window. + * + * @param str + * @param row + * @param col + */ + public void display(String str, int row, int col) { + // Expand references and split on new lines. + String[] lines = buildMessageLines(str, Defines.TEXTCOLS + 1, col); + + for (int i = 0; i < lines.length; i++) { + drawString(this.pixels, lines[i], col * 8, (row + i) * 8); + + // For subsequent lines, we start at column 0 and ignore what was passed in. + col = 0; + } + } + + /** + * Print the given string in an AGI message window. + * + * @param str The text to include in the message window. + */ + public void print(String str) { + windowPrint(str); + } + + /** + * Print the given string in an AGI message window, the window positioned at the given row + * and col, and of the given width. + * + * @param str + * @param row + * @param col + * @param width + */ + public void printAt(String str, int row, int col, int width) { + winULRow = row; + winULCol = col; + + if ((winWidth = width) == 0) { + winWidth = WINWIDTH; + } + + windowPrint(str); + + winWidth = winULRow = winULCol = -1; + } + + /** + * Updates the status line with the score and sound status. + */ + public void updateStatusLine() { + if (state.showStatusLine) { + clearLines(state.statusLineRow, state.statusLineRow, 15); + + StringBuilder scoreStatus = new StringBuilder(); + scoreStatus.append(" Score:"); + scoreStatus.append(state.vars[Defines.SCORE]); + scoreStatus.append(" of "); + scoreStatus.append(state.vars[Defines.MAXSCORE]); + drawString(this.pixels, String.format("%-30s", scoreStatus.toString()), 0, state.statusLineRow * 8, 0, 15); + + StringBuilder soundStatus = new StringBuilder(); + soundStatus.append("Sound:"); + soundStatus.append(state.flags[Defines.SOUNDON] ? "on" : "off"); + drawString(this.pixels, String.format("%-10s", soundStatus.toString()), 30 * 8, state.statusLineRow * 8, 0, 15); + } + } + + public void updateInputLine() { + updateInputLine(true); + } + + /** + * Updates the user input line based on current state. + * + * @param clearWhenNotEnabled + */ + public void updateInputLine(boolean clearWhenNotEnabled) { + if (state.graphicsMode) { + if (state.acceptInput) { + // Input line has the prompt string at the start, then the user input. + StringBuilder inputLine = new StringBuilder(); + if (state.strings[0] != null) { + inputLine.append(expandReferences(state.strings[0])); + } + inputLine.append(state.currentInput.toString()); + if (state.cursorCharacter > 0) { + // Cursor character is optional. There isn't one at the start of the game. + inputLine.append(state.cursorCharacter); + } + + drawString(this.pixels, String.format("%-" + Defines.MAXINPUT +"s", inputLine.toString()), 0, state.inputLineRow * 8); + } + else if (clearWhenNotEnabled) { + // If not accepting input, clear the prompt and text input. + clearLines(state.inputLineRow, state.inputLineRow, 0); + } + } + } + + /** + * Prints the message as a prompt at column 0 of the current input row, then allows the user to + * enter some text. The entered text will have everything other than digits stripped from it, then + * it is converted into a number and returned. + * + * @param message The message to display to the player instructing them what to enter. + * + * @returns The entered number as a byte, or 0 if it can't be converted. + */ + public byte getNum(String message) { + clearLines(state.inputLineRow, state.inputLineRow, 0); + + // Show the prompt message to the user at the specified position. + display(message, state.inputLineRow, 0); + + // Get a line of text from the user. + String line = getLine(4, (byte)state.inputLineRow, (byte)message.length()); + + // Strip out everything that isn't a digit. A little more robust than the original AGI interpreter. + String digitsInLine = line.replaceAll("[^\\d]", ""); + + updateInputLine(); + + return (byte)(digitsInLine.length() > 0? Integer.parseInt(digitsInLine) : 0); + } + + /** + * Prints the message as a prompt at the given screen position, then allows the user to enter + * the string for string number. + * + * @param strNum The number of the user string to put the entered value in to. + * @param message A message to display to the player instructing them what to enter. + * @param row The row to display the message at. + * @param col The column to display the message at. + * @param length The maximum length of the string to get. + */ + public void getString(int strNum, String message, int row, int col, int length) { + // The string cannot be longer than the maximum length for a user string. + length = (byte)(length > Defines.STRLENGTH? Defines.STRLENGTH : length); + + // Show the prompt message to the user at the specified position. + display(message, row, col); + + // Position the input area immediately after the message. + col += (byte)message.length(); + + // Get a line of text from the user. + String line = getLine(length, row, col); + + // If it is not null, i.e. the user didn't hit ESC, then store in user string. + if (line != null) state.strings[strNum] = line; + } + + /** + * Gets a line of user input, echoing the prompt char and entered text at the specified position. + * + * @param length The maximum length of the line of text to get. + * @param row The row on the screen to position the text entry field. + * @param col The column on the screen to position the start of the text entry field. + */ + public String getLine(int length, int row, int col) { + return getLine(length, row, col, "", -1, -1); + } + + /** + * Gets a line of user input, echoing the prompt char and entered text at the specified position. + * + * @param length The maximum length of the line of text to get. + * @param row The row on the screen to position the text entry field. + * @param col The column on the screen to position the start of the text entry field. + * @param str The value to initialise the text entry field with; defaults to empty. + * @param foregroundColour The foreground colour of the text in the text entry field. + * @param backgroundColour The background colour of the text in the text entry field. + * + * @return The entered string if ENTER was hit, otherwise null if ESC was hit. + */ + public String getLine(int length, int row, int col, String str, int foregroundColour, int backgroundColour) { + StringBuilder line = new StringBuilder(str); + + // The string cannot be longer than the maximum length for a GetLine call. + length = (byte)(length > Defines.GLSIZE ? Defines.GLSIZE : length); + + // Process entered keys until either ENTER or ESC is pressed. + while (true) { + // Show the currently entered text. + drawString(this.pixels, (line.toString() + state.cursorCharacter), col * 8, row * 8, foregroundColour, backgroundColour); + + int key = userInput.waitForKey(false); + + if ((key & 0xF0000) == UserInput.ASCII) { + char character = (char)(key & 0xFF); + + if (character == Character.ESC) { + // Exits without returning any entered text. + return null; + } + else if (character == Character.ENTER) { + // If ENTER is hit, we break out of the loop and return the entered line of text. + // Render Line without the cursor by replacing the cursor with empty string + drawString(this.pixels, line.toString() + " ", col * 8, row * 8, foregroundColour, backgroundColour); + break; + } + else if (character == Character.BACKSPACE) { + // Removes one from the end of the currently entered input. + if (line.length() > 0) line.delete(line.length() - 1, line.length()); + + // Render Line with a space overwriting the previous position of the cursor. + drawString(this.pixels, (line.toString() + state.cursorCharacter + " "), col * 8, row * 8, foregroundColour, backgroundColour); + } + else { // Standard char from a keypress event. + // If we haven't reached the max length, add the char to the line of text. + if (line.length() < length) line.append((char)(key & 0xff)); + } + } + } + + return line.toString(); + } + + /** + * Print the string 'str' in a window on the screen and wait for ACCEPT or ABORT + * before disposing of it.Return TRUE for ACCEPT, FALSE for ABORT. + * + * @param str + * + * @return true for ACCEPT, false for ABORT. + */ + public boolean windowPrint(String str) { + return windowPrint(str, null); + } + + /** + * Print the string 'str' in a window on the screen and wait for ACCEPT or ABORT + * before disposing of it.Return TRUE for ACCEPT, FALSE for ABORT. + * + * @param str + * @param aniObj Optional AnimatedObject to draw when the window is opened. + * + * @return true for ACCEPT, false for ABORT. + */ + public boolean windowPrint(String str, AnimatedObject aniObj) { + boolean retVal; + long timeOut; + + // Display the window. + windowNoWait(str, 0, 0, false, aniObj); + + // If we're to leave the window up, just return. + if (state.flags[Defines.LEAVE_WIN] == true) { + state.flags[Defines.LEAVE_WIN] = false; + return true; + } + + // Get the response. + if (state.vars[Defines.PRINT_TIMEOUT] == 0) { + retVal = (userInput.waitAcceptAbort() == UserInput.ACCEPT); + } + else { + // The timeout value is given in half seconds and the TotalTicks in 1/60ths of a second. + timeOut = state.totalTicks + state.vars[Defines.PRINT_TIMEOUT] * 30; + + while ((state.totalTicks < timeOut) && (userInput.checkAcceptAbort() == -1)) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + // Ignore. + } + } + + retVal = true; + + state.vars[Defines.PRINT_TIMEOUT] = 0; + } + + // Close the window. + closeWindow(); + + return retVal; + } + + /** + * + * + * @param str + * @param height + * @param width + * @param fixedSize + * + * @return TextWindow + */ + public TextWindow windowNoWait(String str, int height, int width, boolean fixedSize) { + return windowNoWait(str, height, width, fixedSize, null); + } + + /** + * + * + * @param str + * @param height + * @param width + * @param fixedSize + * @param aniObj Optional AnimatedObject to draw when the window is opened. + * + * @return TextWindow + */ + public TextWindow windowNoWait(String str, int height, int width, boolean fixedSize, AnimatedObject aniObj) { + String[] lines; + int numLines = 0; + + if (openWindow != null) { + closeWindow(); + } + + if ((winWidth == -1) && (width == 0)) { + width = WINWIDTH; + } + else if (winWidth != -1) { + width = winWidth; + } + + while (true) { + // First make a formatting pass through the message, getting maximum line length and number of lines. + lines = buildMessageLines(str, width); + numLines = lines.length; + + if (fixedSize) { + maxLength = width; + if (height != 0) { + numLines = height; + } + } + + if (numLines > (WINBOT - WINTOP)) { + str = String.format("Message too verbose:\n\n\"%s...\"\n\nPress ESC to continue.", str.substring(0, 20)); + } + else { + break; + } + } + + int top = (winULRow == -1 ? WINTOP + (WINBOT - WINTOP - numLines) / 2 : winULRow) + state.pictureRow; + int bottom = top + numLines - 1; + int left = (winULCol == -1 ? (Defines.TEXTCOLS - maxLength) / 2 : winULCol); + int right = left + maxLength; + + // Compute window size and position and put them into the appropriate bytes of the words. + int windowDim = ((numLines * CHARHEIGHT + 2 * VMARGIN) << 8) | (maxLength * CHARWIDTH + 2 * HMARGIN); + int windowPos = ((left * CHARWIDTH - HMARGIN) << 8) | (bottom * CHARHEIGHT + VMARGIN - 1); + + // Open the window, white with a red border and black text. + return openWindow(new TextWindow(windowPos, windowDim, 15, 4, top, left, bottom, right, lines, 0, aniObj)); + } + + /** + * Builds the array of message lines to be included in a message window. The str parameter + * provides the message text, which may contain special % command references that need + * expanding first. After that substitution, the resulting message text is split up on to + * lines that are no longer than the given width, words wrapping down a line if required. + * + * @param str The message text to expand references and split in to lines. + * @param width The maximum width that a message line can be. + * + * @return A String array containing the message lines. + */ + private String[] buildMessageLines(String str, int width) { + return buildMessageLines(str, width, 0); + } + + /** + * Builds the array of message lines to be included in a message window. The str parameter + * provides the message text, which may contain special % command references that need + * expanding first. After that substitution, the resulting message text is split up on to + * lines that are no longer than the given width, words wrapping down a line if required. + * + * @param str The message text to expand references and split in to lines. + * @param width The maximum width that a message line can be. + * @param startColumn Optional starting column value. + * + * @return A String array containing the message lines. + */ + private String[] buildMessageLines(String str, int width, int startColumn) { + List lines = new ArrayList(); + + maxLength = 0; + + if (str != null) { + // Recursively expand/substitute references to other strings. + String processedMessage = expandReferences(str); + + // Now that we have the processed message text, split it in to lines. + StringBuilder currentLine = new StringBuilder(); + + // Pad the first line with however many spaces required to begin at starting column. + if (startColumn > 0) currentLine.append(String.format("%-" + startColumn + "s", "")); + + for (int i = 0; i < processedMessage.length(); i++) { + int addLines = (i == (processedMessage.length() - 1)) ? 1 : 0; + + if (processedMessage.charAt(i) == 0x0A) { + addLines++; + } + else { + // Add the character to the current line. + currentLine.append(processedMessage.charAt(i)); + + // If the current line has reached the width, then word wrap. + if (currentLine.length() >= width) { + i = wrapWord(currentLine, i); + + addLines = 1; + } + } + + while (addLines-- > 0) { + if ((startColumn > 0) && (lines.size() == 0)) { + // Remove the extra padding that we added at the start of first line. + currentLine.delete(0, startColumn); + startColumn = 0; + } + + lines.add(currentLine.toString()); + + if (currentLine.length() > maxLength) { + maxLength = currentLine.length(); + } + + currentLine.setLength(0); + } + } + } + + return (String[])lines.toArray(new String[0]); + } + + /** + * Winds back the given StringBuilder to the last word separate (i.e. space) and adjusts the + * pos index value so that the word that overlapped the max line length is wrapped to the + * next line. + * + * @param str + * + * @return The new position. + */ + private int wrapWord(StringBuilder str, int pos) { + for (int i = str.length() - 1; i >= 0; i--) { + if (str.charAt(i) == ' ') { + pos -= (str.length() - i - 1); + str.delete(i, str.length()); + return pos; + } + } + return pos; + } + + /** + * Scans the given string from the given position for a consecutive sequence of digits. When + * the end is reached, the string of digits is converted in to numeric form and returned. Any + * characters before the given position, and after the end of the sequence of digits, is + * ignored. + * + * @param str + * @param startPos + * + * @return An array containing the number in the first slot and new position in the second. + */ + private int[] numberFromString(String str, int pos) { + int startPos = pos; + while ((pos < str.length()) && (str.charAt(pos) >= '0') && (str.charAt(pos) <= '9')) pos++; + int number = Integer.parseInt(str.substring(startPos, pos--)); + return new int[] { number, pos }; + } + + /** + * Expands the special commands that reference other types of text, such as + * object names, words, other messages, etc. + * + * Messages are strings of fewer than 255 characters which may contain + * the following special commands: + * + * \ Take the next character(except '\n' below) literally + * \n Begin a new line + * %wn Include word number n from the parsed line (1 < = n <= 255) + * %sn Include user defined string number n (0 <= n <= 255) + * %mn Include message number n from this room (0 <= n <= 255) + * %gn Include global message number n from room 0 (0 <= n <= 255) + * %vn|m Print the value of var #n. If the optional '|m' is present, print in a field of width m with leading zeros. + * %on Print the name of the object whose number is in var number n. + * + * + * @param str The string to expand the references of. + * + * @return + */ + private String expandReferences(String str) { + StringBuilder output = new StringBuilder(); + + // Iterate over each character in the message string looking for % codes. + for (int i = 0; i < str.length(); i++) { + if (str.charAt(i) == escapeChar) { + // The '\' character escapes the next character (e.g. \%) + output.append(str.charAt(++i)); + } + else if (str.charAt(i) == '%') { + int num, width; + int[] numPos; + + i++; + + switch (str.charAt(i++)) { + case 'v': + numPos = numberFromString(str, i); + num = numPos[0]; + i = numPos[1]; + if ((i < (str.length() - 1)) && (str.charAt(i + 1) == '|')) { + i += 2; + numPos = numberFromString(str, i); + width = numPos[0]; + i = numPos[1]; + output.append(String.format("%0" + width + "d", state.vars[num])); + } + else { + output.append(state.vars[num]); + } + break; + + case 'm': + numPos = numberFromString(str, i); + num = numPos[0]; + i = numPos[1]; + output.append(state.logics[state.currentLogNum].messages.get(num)); + break; + + case 'g': + numPos = numberFromString(str, i); + num = numPos[0]; + i = numPos[1]; + output.append(state.logics[0].messages.get(num)); + break; + + case 'w': + numPos = numberFromString(str, i); + num = numPos[0]; + i = numPos[1]; + if (num <= state.recognisedWords.size()) { + output.append(state.recognisedWords.get(num - 1)); + } + break; + + case 's': + numPos = numberFromString(str, i); + num = numPos[0]; + i = numPos[1]; + output.append(state.strings[num]); + break; + + case 'o': + numPos = numberFromString(str, i); + num = numPos[0]; + i = numPos[1]; + output.append(state.objects.objects.get(num).name); + break; + + default: // ignore the second character. + break; + } + } + else { + // Default is simply to append the character. + output.append(str.charAt(i)); + } + } + + // Recursive part to make sure all % formatting codes are dealt with. + if (output.toString().contains("%")) { + return expandReferences(output.toString()); + } + else { + return output.toString(); + } + } + + /** + * Opens an AGI window on the game screen. + * + * @param textWindow + * + * @return The same TextWindow with the BackPixels populated. + */ + public TextWindow openWindow(TextWindow textWindow) { + drawWindow(textWindow); + + // Remember this as the currently open window. + this.openWindow = textWindow; + + return textWindow; + } + + /** + * + */ + public void drawWindow() { + drawWindow(null); + } + + /** + * + * + * @param textWindow + */ + public void drawWindow(TextWindow textWindow) { + // Defaults to the currently open window if one was not provided by the caller. + textWindow = (textWindow == null ? openWindow : textWindow); + + if (textWindow != null) { + short backgroundRGB565 = EgaPalette.colours[textWindow.backgroundColour]; + short borderRGB565 = EgaPalette.colours[textWindow.borderColour]; + int startScreenPos = (textWindow.y() * 320) + textWindow.x(); + int screenYAdd = (320 - textWindow.width()); + + // The first time that DrawWindow is invoke for a TextWindow, we store the back pixels. + boolean storeBackPixels = (textWindow.backPixels == null); + if (storeBackPixels) textWindow.backPixels = new short[textWindow.width() * textWindow.height()]; + + // Draw a box in the background colour and store the pixels that were behind it. + int backPixelsPos = 0; + for (int y = 0, screenPos = startScreenPos; y < textWindow.height(); y++, screenPos += screenYAdd) { + for (int x = 0; x < textWindow.width(); x++, screenPos++) { + // Store the pixel currently at this position (if applicable). + if (storeBackPixels) textWindow.backPixels[backPixelsPos++] = this.pixels[screenPos]; + + // Overwrite the pixel with the window's background colour. + this.pixels[screenPos] = backgroundRGB565; + } + } + + // Draw a line just in a bit from the edge of the box in the border colour. + for (int x = 0, screenPos = (startScreenPos + 320 + 2); x < (textWindow.width() - 4); x++, screenPos++) { + this.pixels[screenPos] = borderRGB565; + } + for (int x = 0, screenPos = (startScreenPos + (320 * (textWindow.height() - 2) + 2)); x < (textWindow.width() - 4); x++, screenPos++) { + this.pixels[screenPos] = borderRGB565; + } + for (int y = 1, screenPos = (startScreenPos + 640 + 2); y < (textWindow.height() - 2); y++, screenPos += 320) { + this.pixels[screenPos] = borderRGB565; + this.pixels[screenPos + 1] = borderRGB565; + this.pixels[screenPos + (textWindow.width() - 6)] = borderRGB565; + this.pixels[screenPos + (textWindow.width() - 5)] = borderRGB565; + } + + // Draw the text lines (if applicable). + if (textWindow.textLines != null) { + // Draw the text black on white. + for (int i = 0; i < textWindow.textLines.length; i++) { + drawString(this.pixels, textWindow.textLines[i], (textWindow.left << 3), ((textWindow.top + i) << 3), textWindow.textColour, textWindow.backgroundColour); + } + } + + // Draw the embedded AnimatedObject (if applicable). Supports inventory item description windows. + if (textWindow.aniObj != null) { + textWindow.aniObj.draw(); + textWindow.aniObj.show(pixels); + } + } + } + + /** + * Checks if there is a text window currently open. + * + * @return true if there is a window open; otherwise false. + */ + public boolean isWindowOpen() { + return (this.openWindow != null); + } + + /** + * Closes the current message window. + */ + public void closeWindow() { + closeWindow(true); + } + + /** + * Closes the current message window. + * + * @param restoreBackPixels Whether to restore back pixels or not (defaults to true) + */ + public void closeWindow(boolean restoreBackPixels) { + if (this.openWindow != null) { + if (restoreBackPixels) { + int startScreenPos = (openWindow.y() * 320) + openWindow.x(); + int screenYAdd = (320 - openWindow.width()); + + // Copy each of the stored background pixels back in to their original places. + int backPixelsPos = 0; + for (int y = 0, screenPos = startScreenPos; y < openWindow.height(); y++, screenPos += screenYAdd) { + for (int x = 0; x < openWindow.width(); x++, screenPos++) { + this.pixels[screenPos] = openWindow.backPixels[backPixelsPos++]; + } + } + } + + // Clear the currently open window variable. + this.openWindow = null; + } + } + + /** + * The raw bitmap data for the original IBM PC/PCjr BIOS 8x8 font. + */ + private static final int[] IBM_BIOS_FONT = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x7E, 0x81, 0xA5, 0x81, 0xBD, 0x99, 0x81, 0x7E, + 0x7E, 0xFF, 0xDB, 0xFF, 0xC3, 0xE7, 0xFF, 0x7E, + 0x6C, 0xFE, 0xFE, 0xFE, 0x7C, 0x38, 0x10, 0x00, + 0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00, + 0x38, 0x7C, 0x38, 0xFE, 0xFE, 0x7C, 0x38, 0x7C, + 0x10, 0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x7C, + 0x00, 0x00, 0x18, 0x3C, 0x3C, 0x18, 0x00, 0x00, + 0xFF, 0xFF, 0xE7, 0xC3, 0xC3, 0xE7, 0xFF, 0xFF, + 0x00, 0x3C, 0x66, 0x42, 0x42, 0x66, 0x3C, 0x00, + 0xFF, 0xC3, 0x99, 0xBD, 0xBD, 0x99, 0xC3, 0xFF, + 0x0F, 0x07, 0x0F, 0x7D, 0xCC, 0xCC, 0xCC, 0x78, + 0x3C, 0x66, 0x66, 0x66, 0x3C, 0x18, 0x7E, 0x18, + 0x3F, 0x33, 0x3F, 0x30, 0x30, 0x70, 0xF0, 0xE0, + 0x7F, 0x63, 0x7F, 0x63, 0x63, 0x67, 0xE6, 0xC0, + 0x99, 0x5A, 0x3C, 0xE7, 0xE7, 0x3C, 0x5A, 0x99, + 0x80, 0xE0, 0xF8, 0xFE, 0xF8, 0xE0, 0x80, 0x00, + 0x02, 0x0E, 0x3E, 0xFE, 0x3E, 0x0E, 0x02, 0x00, + 0x18, 0x3C, 0x7E, 0x18, 0x18, 0x7E, 0x3C, 0x18, + 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x66, 0x00, + 0x7F, 0xDB, 0xDB, 0x7B, 0x1B, 0x1B, 0x1B, 0x00, + 0x3E, 0x63, 0x38, 0x6C, 0x6C, 0x38, 0xCC, 0x78, + 0x00, 0x00, 0x00, 0x00, 0x7E, 0x7E, 0x7E, 0x00, + 0x18, 0x3C, 0x7E, 0x18, 0x7E, 0x3C, 0x18, 0xFF, + 0x18, 0x3C, 0x7E, 0x18, 0x18, 0x18, 0x18, 0x00, + 0x18, 0x18, 0x18, 0x18, 0x7E, 0x3C, 0x18, 0x00, + 0x00, 0x18, 0x0C, 0xFE, 0x0C, 0x18, 0x00, 0x00, + 0x00, 0x30, 0x60, 0xFE, 0x60, 0x30, 0x00, 0x00, + 0x00, 0x00, 0xC0, 0xC0, 0xC0, 0xFE, 0x00, 0x00, + 0x00, 0x24, 0x66, 0xFF, 0x66, 0x24, 0x00, 0x00, + 0x00, 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x30, 0x78, 0x78, 0x30, 0x30, 0x00, 0x30, 0x00, + 0x6C, 0x6C, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x6C, 0x6C, 0xFE, 0x6C, 0xFE, 0x6C, 0x6C, 0x00, + 0x30, 0x7C, 0xC0, 0x78, 0x0C, 0xF8, 0x30, 0x00, + 0x00, 0xC6, 0xCC, 0x18, 0x30, 0x66, 0xC6, 0x00, + 0x38, 0x6C, 0x38, 0x76, 0xDC, 0xCC, 0x76, 0x00, + 0x60, 0x60, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x18, 0x30, 0x60, 0x60, 0x60, 0x30, 0x18, 0x00, + 0x60, 0x30, 0x18, 0x18, 0x18, 0x30, 0x60, 0x00, + 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00, + 0x00, 0x30, 0x30, 0xFC, 0x30, 0x30, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x60, + 0x00, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00, + 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x80, 0x00, + 0x7C, 0xC6, 0xCE, 0xDE, 0xF6, 0xE6, 0x7C, 0x00, + 0x30, 0x70, 0x30, 0x30, 0x30, 0x30, 0xFC, 0x00, + 0x78, 0xCC, 0x0C, 0x38, 0x60, 0xCC, 0xFC, 0x00, + 0x78, 0xCC, 0x0C, 0x38, 0x0C, 0xCC, 0x78, 0x00, + 0x1C, 0x3C, 0x6C, 0xCC, 0xFE, 0x0C, 0x1E, 0x00, + 0xFC, 0xC0, 0xF8, 0x0C, 0x0C, 0xCC, 0x78, 0x00, + 0x38, 0x60, 0xC0, 0xF8, 0xCC, 0xCC, 0x78, 0x00, + 0xFC, 0xCC, 0x0C, 0x18, 0x30, 0x30, 0x30, 0x00, + 0x78, 0xCC, 0xCC, 0x78, 0xCC, 0xCC, 0x78, 0x00, + 0x78, 0xCC, 0xCC, 0x7C, 0x0C, 0x18, 0x70, 0x00, + 0x00, 0x30, 0x30, 0x00, 0x00, 0x30, 0x30, 0x00, + 0x00, 0x30, 0x30, 0x00, 0x00, 0x30, 0x30, 0x60, + 0x18, 0x30, 0x60, 0xC0, 0x60, 0x30, 0x18, 0x00, + 0x00, 0x00, 0xFC, 0x00, 0x00, 0xFC, 0x00, 0x00, + 0x60, 0x30, 0x18, 0x0C, 0x18, 0x30, 0x60, 0x00, + 0x78, 0xCC, 0x0C, 0x18, 0x30, 0x00, 0x30, 0x00, + 0x7C, 0xC6, 0xDE, 0xDE, 0xDE, 0xC0, 0x78, 0x00, + 0x30, 0x78, 0xCC, 0xCC, 0xFC, 0xCC, 0xCC, 0x00, + 0xFC, 0x66, 0x66, 0x7C, 0x66, 0x66, 0xFC, 0x00, + 0x3C, 0x66, 0xC0, 0xC0, 0xC0, 0x66, 0x3C, 0x00, + 0xF8, 0x6C, 0x66, 0x66, 0x66, 0x6C, 0xF8, 0x00, + 0xFE, 0x62, 0x68, 0x78, 0x68, 0x62, 0xFE, 0x00, + 0xFE, 0x62, 0x68, 0x78, 0x68, 0x60, 0xF0, 0x00, + 0x3C, 0x66, 0xC0, 0xC0, 0xCE, 0x66, 0x3E, 0x00, + 0xCC, 0xCC, 0xCC, 0xFC, 0xCC, 0xCC, 0xCC, 0x00, + 0x78, 0x30, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00, + 0x1E, 0x0C, 0x0C, 0x0C, 0xCC, 0xCC, 0x78, 0x00, + 0xE6, 0x66, 0x6C, 0x78, 0x6C, 0x66, 0xE6, 0x00, + 0xF0, 0x60, 0x60, 0x60, 0x62, 0x66, 0xFE, 0x00, + 0xC6, 0xEE, 0xFE, 0xFE, 0xD6, 0xC6, 0xC6, 0x00, + 0xC6, 0xE6, 0xF6, 0xDE, 0xCE, 0xC6, 0xC6, 0x00, + 0x38, 0x6C, 0xC6, 0xC6, 0xC6, 0x6C, 0x38, 0x00, + 0xFC, 0x66, 0x66, 0x7C, 0x60, 0x60, 0xF0, 0x00, + 0x78, 0xCC, 0xCC, 0xCC, 0xDC, 0x78, 0x1C, 0x00, + 0xFC, 0x66, 0x66, 0x7C, 0x6C, 0x66, 0xE6, 0x00, + 0x78, 0xCC, 0xE0, 0x70, 0x1C, 0xCC, 0x78, 0x00, + 0xFC, 0xB4, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00, + 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xFC, 0x00, + 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x78, 0x30, 0x00, + 0xC6, 0xC6, 0xC6, 0xD6, 0xFE, 0xEE, 0xC6, 0x00, + 0xC6, 0xC6, 0x6C, 0x38, 0x38, 0x6C, 0xC6, 0x00, + 0xCC, 0xCC, 0xCC, 0x78, 0x30, 0x30, 0x78, 0x00, + 0xFE, 0xC6, 0x8C, 0x18, 0x32, 0x66, 0xFE, 0x00, + 0x78, 0x60, 0x60, 0x60, 0x60, 0x60, 0x78, 0x00, + 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x02, 0x00, + 0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x78, 0x00, + 0x10, 0x38, 0x6C, 0xC6, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, + 0x30, 0x30, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x78, 0x0C, 0x7C, 0xCC, 0x76, 0x00, + 0xE0, 0x60, 0x60, 0x7C, 0x66, 0x66, 0xDC, 0x00, + 0x00, 0x00, 0x78, 0xCC, 0xC0, 0xCC, 0x78, 0x00, + 0x1C, 0x0C, 0x0C, 0x7C, 0xCC, 0xCC, 0x76, 0x00, + 0x00, 0x00, 0x78, 0xCC, 0xFC, 0xC0, 0x78, 0x00, + 0x38, 0x6C, 0x60, 0xF0, 0x60, 0x60, 0xF0, 0x00, + 0x00, 0x00, 0x76, 0xCC, 0xCC, 0x7C, 0x0C, 0xF8, + 0xE0, 0x60, 0x6C, 0x76, 0x66, 0x66, 0xE6, 0x00, + 0x30, 0x00, 0x70, 0x30, 0x30, 0x30, 0x78, 0x00, + 0x0C, 0x00, 0x0C, 0x0C, 0x0C, 0xCC, 0xCC, 0x78, + 0xE0, 0x60, 0x66, 0x6C, 0x78, 0x6C, 0xE6, 0x00, + 0x70, 0x30, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00, + 0x00, 0x00, 0xCC, 0xFE, 0xFE, 0xD6, 0xC6, 0x00, + 0x00, 0x00, 0xF8, 0xCC, 0xCC, 0xCC, 0xCC, 0x00, + 0x00, 0x00, 0x78, 0xCC, 0xCC, 0xCC, 0x78, 0x00, + 0x00, 0x00, 0xDC, 0x66, 0x66, 0x7C, 0x60, 0xF0, + 0x00, 0x00, 0x76, 0xCC, 0xCC, 0x7C, 0x0C, 0x1E, + 0x00, 0x00, 0xDC, 0x76, 0x66, 0x60, 0xF0, 0x00, + 0x00, 0x00, 0x7C, 0xC0, 0x78, 0x0C, 0xF8, 0x00, + 0x10, 0x30, 0x7C, 0x30, 0x30, 0x34, 0x18, 0x00, + 0x00, 0x00, 0xCC, 0xCC, 0xCC, 0xCC, 0x76, 0x00, + 0x00, 0x00, 0xCC, 0xCC, 0xCC, 0x78, 0x30, 0x00, + 0x00, 0x00, 0xC6, 0xD6, 0xFE, 0xFE, 0x6C, 0x00, + 0x00, 0x00, 0xC6, 0x6C, 0x38, 0x6C, 0xC6, 0x00, + 0x00, 0x00, 0xCC, 0xCC, 0xCC, 0x7C, 0x0C, 0xF8, + 0x00, 0x00, 0xFC, 0x98, 0x30, 0x64, 0xFC, 0x00, + 0x1C, 0x30, 0x30, 0xE0, 0x30, 0x30, 0x1C, 0x00, + 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00, + 0xE0, 0x30, 0x30, 0x1C, 0x30, 0x30, 0xE0, 0x00, + 0x76, 0xDC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x10, 0x38, 0x6C, 0xC6, 0xC6, 0xFE, 0x00, + 0x78, 0xCC, 0xC0, 0xCC, 0x78, 0x18, 0x0C, 0x78, + 0x00, 0xCC, 0x00, 0xCC, 0xCC, 0xCC, 0x7E, 0x00, + 0x1C, 0x00, 0x78, 0xCC, 0xFC, 0xC0, 0x78, 0x00, + 0x7E, 0xC3, 0x3C, 0x06, 0x3E, 0x66, 0x3F, 0x00, + 0xCC, 0x00, 0x78, 0x0C, 0x7C, 0xCC, 0x7E, 0x00, + 0xE0, 0x00, 0x78, 0x0C, 0x7C, 0xCC, 0x7E, 0x00, + 0x30, 0x30, 0x78, 0x0C, 0x7C, 0xCC, 0x7E, 0x00, + 0x00, 0x00, 0x78, 0xC0, 0xC0, 0x78, 0x0C, 0x38, + 0x7E, 0xC3, 0x3C, 0x66, 0x7E, 0x60, 0x3C, 0x00, + 0xCC, 0x00, 0x78, 0xCC, 0xFC, 0xC0, 0x78, 0x00, + 0xE0, 0x00, 0x78, 0xCC, 0xFC, 0xC0, 0x78, 0x00, + 0xCC, 0x00, 0x70, 0x30, 0x30, 0x30, 0x78, 0x00, + 0x7C, 0xC6, 0x38, 0x18, 0x18, 0x18, 0x3C, 0x00, + 0xE0, 0x00, 0x70, 0x30, 0x30, 0x30, 0x78, 0x00, + 0xC6, 0x38, 0x6C, 0xC6, 0xFE, 0xC6, 0xC6, 0x00, + 0x30, 0x30, 0x00, 0x78, 0xCC, 0xFC, 0xCC, 0x00, + 0x1C, 0x00, 0xFC, 0x60, 0x78, 0x60, 0xFC, 0x00, + 0x00, 0x00, 0x7F, 0x0C, 0x7F, 0xCC, 0x7F, 0x00, + 0x3E, 0x6C, 0xCC, 0xFE, 0xCC, 0xCC, 0xCE, 0x00, + 0x78, 0xCC, 0x00, 0x78, 0xCC, 0xCC, 0x78, 0x00, + 0x00, 0xCC, 0x00, 0x78, 0xCC, 0xCC, 0x78, 0x00, + 0x00, 0xE0, 0x00, 0x78, 0xCC, 0xCC, 0x78, 0x00, + 0x78, 0xCC, 0x00, 0xCC, 0xCC, 0xCC, 0x7E, 0x00, + 0x00, 0xE0, 0x00, 0xCC, 0xCC, 0xCC, 0x7E, 0x00, + 0x00, 0xCC, 0x00, 0xCC, 0xCC, 0x7C, 0x0C, 0xF8, + 0xC3, 0x18, 0x3C, 0x66, 0x66, 0x3C, 0x18, 0x00, + 0xCC, 0x00, 0xCC, 0xCC, 0xCC, 0xCC, 0x78, 0x00, + 0x18, 0x18, 0x7E, 0xC0, 0xC0, 0x7E, 0x18, 0x18, + 0x38, 0x6C, 0x64, 0xF0, 0x60, 0xE6, 0xFC, 0x00, + 0xCC, 0xCC, 0x78, 0xFC, 0x30, 0xFC, 0x30, 0x30, + 0xF8, 0xCC, 0xCC, 0xFA, 0xC6, 0xCF, 0xC6, 0xC7, + 0x0E, 0x1B, 0x18, 0x3C, 0x18, 0x18, 0xD8, 0x70, + 0x1C, 0x00, 0x78, 0x0C, 0x7C, 0xCC, 0x7E, 0x00, + 0x38, 0x00, 0x70, 0x30, 0x30, 0x30, 0x78, 0x00, + 0x00, 0x1C, 0x00, 0x78, 0xCC, 0xCC, 0x78, 0x00, + 0x00, 0x1C, 0x00, 0xCC, 0xCC, 0xCC, 0x7E, 0x00, + 0x00, 0xF8, 0x00, 0xF8, 0xCC, 0xCC, 0xCC, 0x00, + 0xFC, 0x00, 0xCC, 0xEC, 0xFC, 0xDC, 0xCC, 0x00, + 0x3C, 0x6C, 0x6C, 0x3E, 0x00, 0x7E, 0x00, 0x00, + 0x38, 0x6C, 0x6C, 0x38, 0x00, 0x7C, 0x00, 0x00, + 0x30, 0x00, 0x30, 0x60, 0xC0, 0xCC, 0x78, 0x00, + 0x00, 0x00, 0x00, 0xFC, 0xC0, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xFC, 0x0C, 0x0C, 0x00, 0x00, + 0xC3, 0xC6, 0xCC, 0xDE, 0x33, 0x66, 0xCC, 0x0F, + 0xC3, 0xC6, 0xCC, 0xDB, 0x37, 0x6F, 0xCF, 0x03, + 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x18, 0x00, + 0x00, 0x33, 0x66, 0xCC, 0x66, 0x33, 0x00, 0x00, + 0x00, 0xCC, 0x66, 0x33, 0x66, 0xCC, 0x00, 0x00, + 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, + 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, + 0xDB, 0x77, 0xDB, 0xEE, 0xDB, 0x77, 0xDB, 0xEE, + 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x18, 0xF8, 0x18, 0x18, 0x18, + 0x18, 0x18, 0xF8, 0x18, 0xF8, 0x18, 0x18, 0x18, + 0x36, 0x36, 0x36, 0x36, 0xF6, 0x36, 0x36, 0x36, + 0x00, 0x00, 0x00, 0x00, 0xFE, 0x36, 0x36, 0x36, + 0x00, 0x00, 0xF8, 0x18, 0xF8, 0x18, 0x18, 0x18, + 0x36, 0x36, 0xF6, 0x06, 0xF6, 0x36, 0x36, 0x36, + 0x36, 0x36, 0x36, 0x36, 0x36, 0x36, 0x36, 0x36, + 0x00, 0x00, 0xFE, 0x06, 0xF6, 0x36, 0x36, 0x36, + 0x36, 0x36, 0xF6, 0x06, 0xFE, 0x00, 0x00, 0x00, + 0x36, 0x36, 0x36, 0x36, 0xFE, 0x00, 0x00, 0x00, + 0x18, 0x18, 0xF8, 0x18, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xF8, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x18, 0x1F, 0x00, 0x00, 0x00, + 0x18, 0x18, 0x18, 0x18, 0xFF, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xFF, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x18, 0x1F, 0x18, 0x18, 0x18, + 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, + 0x18, 0x18, 0x18, 0x18, 0xFF, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x1F, 0x18, 0x1F, 0x18, 0x18, 0x18, + 0x36, 0x36, 0x36, 0x36, 0x37, 0x36, 0x36, 0x36, + 0x36, 0x36, 0x37, 0x30, 0x3F, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x3F, 0x30, 0x37, 0x36, 0x36, 0x36, + 0x36, 0x36, 0xF7, 0x00, 0xFF, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0x00, 0xF7, 0x36, 0x36, 0x36, + 0x36, 0x36, 0x37, 0x30, 0x37, 0x36, 0x36, 0x36, + 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x00, + 0x36, 0x36, 0xF7, 0x00, 0xF7, 0x36, 0x36, 0x36, + 0x18, 0x18, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x00, + 0x36, 0x36, 0x36, 0x36, 0xFF, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x18, 0x18, 0x18, + 0x00, 0x00, 0x00, 0x00, 0xFF, 0x36, 0x36, 0x36, + 0x36, 0x36, 0x36, 0x36, 0x3F, 0x00, 0x00, 0x00, + 0x18, 0x18, 0x1F, 0x18, 0x1F, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x1F, 0x18, 0x1F, 0x18, 0x18, 0x18, + 0x00, 0x00, 0x00, 0x00, 0x3F, 0x36, 0x36, 0x36, + 0x36, 0x36, 0x36, 0x36, 0xFF, 0x36, 0x36, 0x36, + 0x18, 0x18, 0xFF, 0x18, 0xFF, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x18, 0xF8, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x1F, 0x18, 0x18, 0x18, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, + 0x0F, 0x0F, 0x0F, 0x0F, 0x0F, 0x0F, 0x0F, 0x0F, + 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x76, 0xDC, 0xC8, 0xDC, 0x76, 0x00, + 0x00, 0x78, 0xCC, 0xF8, 0xCC, 0xF8, 0xC0, 0xC0, + 0x00, 0xFC, 0xCC, 0xC0, 0xC0, 0xC0, 0xC0, 0x00, + 0x00, 0xFE, 0x6C, 0x6C, 0x6C, 0x6C, 0x6C, 0x00, + 0xFC, 0xCC, 0x60, 0x30, 0x60, 0xCC, 0xFC, 0x00, + 0x00, 0x00, 0x7E, 0xD8, 0xD8, 0xD8, 0x70, 0x00, + 0x00, 0x66, 0x66, 0x66, 0x66, 0x7C, 0x60, 0xC0, + 0x00, 0x76, 0xDC, 0x18, 0x18, 0x18, 0x18, 0x00, + 0xFC, 0x30, 0x78, 0xCC, 0xCC, 0x78, 0x30, 0xFC, + 0x38, 0x6C, 0xC6, 0xFE, 0xC6, 0x6C, 0x38, 0x00, + 0x38, 0x6C, 0xC6, 0xC6, 0x6C, 0x6C, 0xEE, 0x00, + 0x1C, 0x30, 0x18, 0x7C, 0xCC, 0xCC, 0x78, 0x00, + 0x00, 0x00, 0x7E, 0xDB, 0xDB, 0x7E, 0x00, 0x00, + 0x06, 0x0C, 0x7E, 0xDB, 0xDB, 0x7E, 0x60, 0xC0, + 0x38, 0x60, 0xC0, 0xF8, 0xC0, 0x60, 0x38, 0x00, + 0x78, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x00, + 0x00, 0xFC, 0x00, 0xFC, 0x00, 0xFC, 0x00, 0x00, + 0x30, 0x30, 0xFC, 0x30, 0x30, 0x00, 0xFC, 0x00, + 0x60, 0x30, 0x18, 0x30, 0x60, 0x00, 0xFC, 0x00, + 0x18, 0x30, 0x60, 0x30, 0x18, 0x00, 0xFC, 0x00, + 0x0E, 0x1B, 0x1B, 0x18, 0x18, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x18, 0x18, 0xD8, 0xD8, 0x70, + 0x30, 0x30, 0x00, 0xFC, 0x00, 0x30, 0x30, 0x00, + 0x00, 0x76, 0xDC, 0x00, 0x76, 0xDC, 0x00, 0x00, + 0x38, 0x6C, 0x6C, 0x38, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x0F, 0x0C, 0x0C, 0x0C, 0xEC, 0x6C, 0x3C, 0x1C, + 0x78, 0x6C, 0x6C, 0x6C, 0x6C, 0x00, 0x00, 0x00, + 0x70, 0x18, 0x30, 0x60, 0x78, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x3C, 0x3C, 0x3C, 0x3C, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; +} diff --git a/core/src/main/java/com/agifans/agile/UserInput.java b/core/src/main/java/com/agifans/agile/UserInput.java new file mode 100644 index 0000000..fecd5c7 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/UserInput.java @@ -0,0 +1,480 @@ +package com.agifans.agile; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedQueue; + +import com.badlogic.gdx.Input.Keys; +import com.badlogic.gdx.InputAdapter; + +/** + * Handles the input of keyboard events, mapping them to a form that the AGILE + * interpreter can query as required. + */ +public class UserInput extends InputAdapter { + + /** + * The SHIFT modifier key. + */ + private static final int SHIFT_MODIFIER = 0x10000; + + /** + * The CTRL modifier key. + */ + private static final int CONTROL_MODIFIER = 0x20000; + + /** + * The ALT modifier key. + */ + private static final int ALT_MODIFIER = 0x40000; + + /** + * Marks the enqueued keycode value as an ASCII character. + */ + public static final int ASCII = 0x80000; + + // AGI ACCEPT/ABORT input values. + public static final int ACCEPT = 0; + public static final int ABORT = 1; + + /** + * A queue of all key presses that the user has made. + */ + public ConcurrentLinkedQueue keyPressQueue; + + /** + * Current state of every key on the keyboard. + */ + public boolean[] keys; + + /** + * Stores the state of every key on the previous cycle. + */ + public boolean[] oldKeys; + + /** + * Current state of the ALT/SHIFT/CONTROL modifiers, as bit mask. + */ + private int modifiers; + + /** + * A Map between IBM PC key codes as understood by the PC AGI interpreter and the C# Key codes. + */ + public Map keyCodeMap; + + public Map reverseKeyCodeMap; + + /** + * Unmodified LibGDX key values that we will enqueue as-is. + */ + private static final List UNMODIFIED_KEY_LIST = Arrays.asList( + Keys.F1, + Keys.F2, + Keys.F3, + Keys.F4, + Keys.F5, + Keys.F6, + Keys.F7, + Keys.F8, + Keys.F9, + Keys.F10, + Keys.HOME, + Keys.UP, + Keys.PAGE_UP, + Keys.LEFT, + Keys.RIGHT, + Keys.END, + Keys.DOWN, + Keys.PAGE_DOWN, + Keys.HOME, + Keys.INSERT, + Keys.DEL); + + /** + * Constructor for UserInput. + */ + public UserInput() { + this.keys = new boolean[256]; + this.oldKeys = new boolean[256]; + this.keyPressQueue = new ConcurrentLinkedQueue(); + this.keyCodeMap = createKeyConversionMap(); + this.reverseKeyCodeMap = new HashMap(); + for (Map.Entry entry : keyCodeMap.entrySet()) { + if (!reverseKeyCodeMap.containsKey(entry.getValue()) && (entry.getValue() != 0)) { + reverseKeyCodeMap.put(entry.getValue(), entry.getKey()); + } + } + } + + /** + * Handles the key down event. + * + * @param keycode one of the constants in {@link Character.Keys} + * + * @return whether the input was processed + */ + public boolean keyDown (int keycode) { + //System.out.println(String.format("keyDown: 0x%04X [modifiers=0x%05X]", + // (int)keycode, modifiers)); + + // AGILE interpreter ignores some keys completely, e.g. F11. + if (keycode == Keys.F11) { + return false; + } + + this.keys[keycode & 0xFF] = true; + + // Update modifies for ALT/SHIFT/CONTROL but do not enqueue key presses that + // are Alt/Shift/Ctrl by themselves. AGI doesn't support mapping those. + if ((keycode == Keys.SHIFT_LEFT) || (keycode == Keys.SHIFT_RIGHT)) { + return true; + } + if ((keycode == Keys.ALT_LEFT) || (keycode == Keys.ALT_RIGHT)) { + modifiers |= ALT_MODIFIER; + return true; + } + if ((keycode == Keys.CONTROL_LEFT) || (keycode == Keys.CONTROL_RIGHT)) { + modifiers |= CONTROL_MODIFIER; + return true; + } + + // Some keys and key combinations we need to map to ASCII characters. + Integer character = Character.KEYSTROKE_TO_CHAR_MAP.get(modifiers + keycode); + if (character != null) { + // Enqueue the mapped character. This covers CTRL combinations and ESC. + keyPressQueue.add(ASCII | character); + } + else if (modifiers != 0) { + // Enqueues the ALT combinations. + keyPressQueue.add(modifiers | keycode); + } + else if (UNMODIFIED_KEY_LIST.contains(keycode)) { + // Any other keycode that didn't map to a character, and isn't affected + // by modifiers, is enqueued as-is. + keyPressQueue.add(keycode); + } + + return true; + } + + /** + * Handles the key up event. + */ + public boolean keyUp(int keycode) { + //System.out.println(String.format("keyUp: 0x%04X [modifiers=0x%05X]", + // (int)keycode, modifiers)); + + this.keys[keycode & 0xFF] = false; + + // Update modifiers for ALT/CONTROL + if ((keycode == Keys.ALT_LEFT) || (keycode == Keys.ALT_RIGHT)) { + modifiers &= (~ALT_MODIFIER); + return true; + } + if ((keycode == Keys.CONTROL_LEFT) || (keycode == Keys.CONTROL_RIGHT)) { + modifiers &= (~CONTROL_MODIFIER); + return true; + } + + return true; + } + + /** + * Handles the key pressed event. + * + * @param character The character that was typed. + */ + public boolean keyTyped(char character) { + //System.out.println(String.format("keyTyped: 0x%02X [modifiers=0x%05X]", + // (int)character, modifiers)); + + // NOTE: The keyTyped method isn't invoked when ALT and CTRL are used. + + // We handle ENTER ourselves in keyDown, via the HashMap in Character class. + if ((character != 0x0A) && (character != 0x0D)) { + keyPressQueue.add(ASCII | (int)character); + } + + return true; + } + + /** + * Wait for and return either ACCEPT or ABORT. + * + * @return Either ACCEPT or ABORT, depending on what was chosen. + */ + public int waitAcceptAbort() { + int action; + + // Ignore anything currently on the key press queue. + while (keyPressQueue.poll() != null) ; + + // Now wait for the the next key. + while ((action = checkAcceptAbort()) == -1) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + // Ignore. + } + } + + return action; + } + + /** + * Waits for the next key to be pressed then returns the value. Always clears + * the key press queue beforehand. + * + * @return The key that was pressed. + */ + public int waitForKey() { + return waitForKey(true); + } + + /** + * Waits for the next key to be pressed then returns the value. + * + * @param clearQueue Whether to clear what is on the queue before waiting. + * + * @returnThe key that was pressed. + */ + public int waitForKey(boolean clearQueue) { + int key; + + if (clearQueue) { + // Ignore anything currently on the key press queue. + while (keyPressQueue.poll() != null) ; + } + + // Now wait for the the next key. + while ((key = getKey()) == 0) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + // Ignore. + } + } + + return key; + } + + /** + * Check if either ACCEPT or ABORT has been selected. Return the value if so, -1 otherwise. + * + * @return Either ACCEPT or ABORT; otherwise -1 if neither was selected. + */ + public int checkAcceptAbort() { + int c; + + if ((c = getKey()) == (ASCII | Character.ENTER)) { + return ACCEPT; + } + else if (c == (ASCII | Character.ESC)) { + return ABORT; + } + else { + return -1; + } + } + + /** + * Gets a key from the key queue. Return 0 if none available. + * + * @return Either the key from the queue, or 0 if none available. + */ + public int getKey() { + return (keyPressQueue.peek() != null? keyPressQueue.poll() : 0); + } + + /** + * Creates the Map between key codes as understood by the PC AGI interpreter + * and the libGDX Key codes. + */ + private Map createKeyConversionMap() { + Map controllerMap = new HashMap<>(); + + controllerMap.put(9, ASCII | Character.TAB); + controllerMap.put(27, ASCII | Character.ESC); + controllerMap.put(13, ASCII | Character.ENTER); + + // Function keys. + controllerMap.put((59 << 8) + 0, Keys.F1); + controllerMap.put((60 << 8) + 0, Keys.F2); + controllerMap.put((61 << 8) + 0, Keys.F3); + controllerMap.put((62 << 8) + 0, Keys.F4); + controllerMap.put((63 << 8) + 0, Keys.F5); + controllerMap.put((64 << 8) + 0, Keys.F6); + controllerMap.put((65 << 8) + 0, Keys.F7); + controllerMap.put((66 << 8) + 0, Keys.F8); + controllerMap.put((67 << 8) + 0, Keys.F9); + controllerMap.put((68 << 8) + 0, Keys.F10); + + // Control and another key. + controllerMap.put(1, ASCII | Character.CTRL_A); + controllerMap.put(2, ASCII | Character.CTRL_B); + controllerMap.put(3, ASCII | Character.CTRL_C); + controllerMap.put(4, ASCII | Character.CTRL_D); + controllerMap.put(5, ASCII | Character.CTRL_E); + controllerMap.put(6, ASCII | Character.CTRL_F); + controllerMap.put(7, ASCII | Character.CTRL_G); + controllerMap.put(8, ASCII | Character.CTRL_H); + controllerMap.put(10, ASCII | Character.CTRL_J); + controllerMap.put(11, ASCII | Character.CTRL_K); + controllerMap.put(12, ASCII | Character.CTRL_L); + controllerMap.put(14, ASCII | Character.CTRL_N); + controllerMap.put(15, ASCII | Character.CTRL_O); + controllerMap.put(16, ASCII | Character.CTRL_P); + controllerMap.put(17, ASCII | Character.CTRL_Q); + controllerMap.put(18, ASCII | Character.CTRL_R); + controllerMap.put(19, ASCII | Character.CTRL_S); + controllerMap.put(20, ASCII | Character.CTRL_T); + controllerMap.put(21, ASCII | Character.CTRL_U); + controllerMap.put(22, ASCII | Character.CTRL_V); + controllerMap.put(23, ASCII | Character.CTRL_W); + controllerMap.put(24, ASCII | Character.CTRL_X); + controllerMap.put(25, ASCII | Character.CTRL_Y); + controllerMap.put(26, ASCII | Character.CTRL_Z); + + // Alt and another key. + controllerMap.put((16 << 8) + 0, ALT_MODIFIER | Keys.Q); + controllerMap.put((17 << 8) + 0, ALT_MODIFIER | Keys.W); + controllerMap.put((18 << 8) + 0, ALT_MODIFIER | Keys.E); + controllerMap.put((19 << 8) + 0, ALT_MODIFIER | Keys.R); + controllerMap.put((20 << 8) + 0, ALT_MODIFIER | Keys.T); + controllerMap.put((21 << 8) + 0, ALT_MODIFIER | Keys.Y); + controllerMap.put((22 << 8) + 0, ALT_MODIFIER | Keys.U); + controllerMap.put((23 << 8) + 0, ALT_MODIFIER | Keys.I); + controllerMap.put((24 << 8) + 0, ALT_MODIFIER | Keys.O); + controllerMap.put((25 << 8) + 0, ALT_MODIFIER | Keys.P); + controllerMap.put((30 << 8) + 0, ALT_MODIFIER | Keys.A); + controllerMap.put((31 << 8) + 0, ALT_MODIFIER | Keys.S); + controllerMap.put((32 << 8) + 0, ALT_MODIFIER | Keys.D); + controllerMap.put((33 << 8) + 0, ALT_MODIFIER | Keys.F); + controllerMap.put((34 << 8) + 0, ALT_MODIFIER | Keys.G); + controllerMap.put((35 << 8) + 0, ALT_MODIFIER | Keys.H); + controllerMap.put((36 << 8) + 0, ALT_MODIFIER | Keys.J); + controllerMap.put((37 << 8) + 0, ALT_MODIFIER | Keys.K); + controllerMap.put((38 << 8) + 0, ALT_MODIFIER | Keys.L); + controllerMap.put((44 << 8) + 0, ALT_MODIFIER | Keys.Z); + controllerMap.put((45 << 8) + 0, ALT_MODIFIER | Keys.X); + controllerMap.put((46 << 8) + 0, ALT_MODIFIER | Keys.C); + controllerMap.put((47 << 8) + 0, ALT_MODIFIER | Keys.V); + controllerMap.put((48 << 8) + 0, ALT_MODIFIER | Keys.B); + controllerMap.put((49 << 8) + 0, ALT_MODIFIER | Keys.N); + controllerMap.put((50 << 8) + 0, ALT_MODIFIER | Keys.M); + + controllerMap.put(28, ASCII | Character.CTRL_BACK_SLASH); + controllerMap.put(29, ASCII | Character.CTRL_CLOSE_SQUARE_BRACKET); + controllerMap.put(30, ASCII | Character.CTRL_6); + controllerMap.put(31, ASCII | Character.CTRL_MINUS); + + // Normal printable chars. + controllerMap.put(32, (ASCII | ' ')); + controllerMap.put(33, (ASCII | '!')); + controllerMap.put(34, (ASCII | '"')); + controllerMap.put(35, (ASCII | '#')); + controllerMap.put(36, (ASCII | '$')); + controllerMap.put(37, (ASCII | '%')); + controllerMap.put(38, (ASCII | '&')); + controllerMap.put(39, (ASCII | '\'')); + controllerMap.put(40, (ASCII | '(')); + controllerMap.put(41, (ASCII | ')')); + controllerMap.put(42, (ASCII | '*')); + controllerMap.put(43, (ASCII | '+')); + controllerMap.put(44, (ASCII | ',')); + controllerMap.put(45, (ASCII | '-')); + controllerMap.put(46, (ASCII | '.')); + controllerMap.put(47, (ASCII | '/')); + controllerMap.put(48, (ASCII | '0')); + controllerMap.put(49, (ASCII | '1')); + controllerMap.put(50, (ASCII | '2')); + controllerMap.put(51, (ASCII | '3')); + controllerMap.put(52, (ASCII | '4')); + controllerMap.put(53, (ASCII | '5')); + controllerMap.put(54, (ASCII | '6')); + controllerMap.put(55, (ASCII | '7')); + controllerMap.put(56, (ASCII | '8')); + controllerMap.put(57, (ASCII | '9')); + controllerMap.put(58, (ASCII | ':')); + controllerMap.put(59, (ASCII | ';')); + controllerMap.put(60, (ASCII | '<')); + controllerMap.put(61, (ASCII | '=')); + controllerMap.put(62, (ASCII | '>')); + controllerMap.put(63, (ASCII | '?')); + controllerMap.put(64, (ASCII | '@')); + + // Manhunter games use unmodified alpha chars as controllers, e.g. C and S. AGI Demo Packs do as well. + controllerMap.put(65, (ASCII | 'a')); + controllerMap.put(66, (ASCII | 'b')); + controllerMap.put(67, (ASCII | 'c')); + controllerMap.put(68, (ASCII | 'd')); + controllerMap.put(69, (ASCII | 'e')); + controllerMap.put(70, (ASCII | 'f')); + controllerMap.put(71, (ASCII | 'g')); + controllerMap.put(72, (ASCII | 'h')); + controllerMap.put(73, (ASCII | 'i')); + controllerMap.put(74, (ASCII | 'j')); + controllerMap.put(75, (ASCII | 'k')); + controllerMap.put(76, (ASCII | 'l')); + controllerMap.put(77, (ASCII | 'm')); + controllerMap.put(78, (ASCII | 'n')); + controllerMap.put(79, (ASCII | 'o')); + controllerMap.put(80, (ASCII | 'p')); + controllerMap.put(81, (ASCII | 'q')); + controllerMap.put(82, (ASCII | 'r')); + controllerMap.put(83, (ASCII | 's')); + controllerMap.put(84, (ASCII | 't')); + controllerMap.put(85, (ASCII | 'u')); + controllerMap.put(86, (ASCII | 'v')); + controllerMap.put(87, (ASCII | 'w')); + controllerMap.put(88, (ASCII | 'x')); + controllerMap.put(89, (ASCII | 'y')); + controllerMap.put(90, (ASCII | 'z')); + controllerMap.put(91, (ASCII | '[')); + controllerMap.put(92, (ASCII | '\\')); + controllerMap.put(93, (ASCII | ']')); + controllerMap.put(94, (ASCII | '^')); + controllerMap.put(95, (ASCII | '_')); + controllerMap.put(96, (ASCII | '`')); + controllerMap.put(97, (ASCII | 'A')); + controllerMap.put(98, (ASCII | 'B')); + controllerMap.put(99, (ASCII | 'C')); + controllerMap.put(100, (ASCII | 'D')); + controllerMap.put(101, (ASCII | 'E')); + controllerMap.put(102, (ASCII | 'F')); + controllerMap.put(103, (ASCII | 'G')); + controllerMap.put(104, (ASCII | 'H')); + controllerMap.put(105, (ASCII | 'I')); + controllerMap.put(106, (ASCII | 'J')); + controllerMap.put(107, (ASCII | 'K')); + controllerMap.put(108, (ASCII | 'L')); + controllerMap.put(109, (ASCII | 'M')); + controllerMap.put(110, (ASCII | 'N')); + controllerMap.put(111, (ASCII | 'O')); + controllerMap.put(112, (ASCII | 'P')); + controllerMap.put(113, (ASCII | 'Q')); + controllerMap.put(114, (ASCII | 'R')); + controllerMap.put(115, (ASCII | 'S')); + controllerMap.put(116, (ASCII | 'T')); + controllerMap.put(117, (ASCII | 'U')); + controllerMap.put(118, (ASCII | 'V')); + controllerMap.put(119, (ASCII | 'W')); + controllerMap.put(120, (ASCII | 'X')); + controllerMap.put(121, (ASCII | 'Y')); + controllerMap.put(122, (ASCII | 'Z')); + controllerMap.put(123, (ASCII | '{')); + controllerMap.put(124, (ASCII | '|')); + controllerMap.put(125, (ASCII | '}')); + controllerMap.put(126, (ASCII | '~')); + + // Joysick codes. We're going to ignore these for now. Who uses a Joystick anyway? Maybe in the 80s. :) + controllerMap.put((1 << 8) + 1, 0); + controllerMap.put((1 << 8) + 2, 0); + controllerMap.put((1 << 8) + 3, 0); + controllerMap.put((1 << 8) + 4, 0); + + return controllerMap; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/agifans/agile/WavePlayer.java b/core/src/main/java/com/agifans/agile/WavePlayer.java new file mode 100644 index 0000000..e061994 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/WavePlayer.java @@ -0,0 +1,50 @@ +package com.agifans.agile; + +/** + * An Interface for playing WAV data. The desktop, mobile, and HTML platforms will + * implement this in their own way. We generally have a lot more control over sound + * if we ignore the libgdx platform independent audio classes and instead use + * platform specific audio techniques. This is particularly the case for HTML. It is + * doesn't seem possible to play a WAV file from a byte array and request callback + * at the end when using libgdx, but its perfectly possible in JavaScript. + */ +public interface WavePlayer { + + /** + * Plays the given WAV file data, and when finished, calls the given + * endCallback Runnable. + * + * @param waveData A byte array containing the WAV data to play. + * @param endedCallback The callback Runnable to run when finished. + */ + void playWaveData(byte[] waveData, Runnable endedCallback); + + /** + * Request the WavePlayer implementation to stop playing the WAV. + * + * @param wait @param wait true to wait for the WAV player to finish playing; otherwise false to not wait. + */ + void stopPlaying(boolean wait); + + /** + * Returns true if the sound is still playing. + * + * @return true if the sound is still playing; otherwise false. + */ + boolean isPlaying(); + + /** + * Resets the state of the WavePlayer, as if it is newly instantiated. This is + * intended to be calling in scenarios such as when the room has changed, or + * when a saved game has been restored. The platform specific implementations may + * or may not actually do anything. + */ + void reset(); + + /** + * Dispose of any audio device objects that were created to support sound + * play back. Whether this does anything or not depends on the platform specific + * implementation. + */ + void dispose(); +} diff --git a/core/src/main/java/com/agifans/agile/agilib/AgileLogicProvider.java b/core/src/main/java/com/agifans/agile/agilib/AgileLogicProvider.java new file mode 100644 index 0000000..b8f4421 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/AgileLogicProvider.java @@ -0,0 +1,36 @@ +package com.agifans.agile.agilib; + +import java.io.IOException; +import java.io.InputStream; + +import com.sierra.agi.io.IOUtils; +import com.sierra.agi.logic.Logic; +import com.sierra.agi.logic.LogicException; +import com.sierra.agi.logic.LogicProvider; + +/** + * An implementation of the JAGI LogicProvider interface that loads the Logic + * in a form more easily used by AGILE. + */ +public class AgileLogicProvider implements LogicProvider { + + @Override + public Logic loadLogic(short logicNumber, InputStream inputStream, int size) throws IOException, LogicException { + byte[] rawData = new byte[size]; + IOUtils.fill(inputStream, rawData, 0, size); + return new AgileLogicWrapper(new com.agifans.agile.agilib.Logic(rawData, false)); + } + + public static class AgileLogicWrapper implements Logic { + + private com.agifans.agile.agilib.Logic agileLogic; + + public AgileLogicWrapper(com.agifans.agile.agilib.Logic agileLogic) { + this.agileLogic = agileLogic; + } + + public com.agifans.agile.agilib.Logic getAgileLogic() { + return agileLogic; + } + } +} diff --git a/core/src/main/java/com/agifans/agile/agilib/AgileSoundProvider.java b/core/src/main/java/com/agifans/agile/agilib/AgileSoundProvider.java new file mode 100644 index 0000000..79ae130 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/AgileSoundProvider.java @@ -0,0 +1,46 @@ +package com.agifans.agile.agilib; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import com.sierra.agi.sound.Sound; +import com.sierra.agi.sound.SoundProvider; + +/** + * An implementation of the JAGI SoundProvider interface that loads the Sound + * in a form more easily used by AGILE. + */ +public class AgileSoundProvider implements SoundProvider { + + @Override + public Sound loadSound(InputStream is) throws IOException { + // At this point, JAGI has already read the 5 byte header, i.e. + // 0x12 0x34, etc., which means that the InputStream does not contain + // the length. We therefore have to fully read the resource from + // the InputStream so as to create the byte array required by + // the AGILE Sound resource. Avoiding Java 9 at present, as it is + // unclear whether GWT will support this. + int numOfBytesReads; + byte[] data = new byte[256]; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + while ((numOfBytesReads = is.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, numOfBytesReads); + } + buffer.flush(); + return new AgileSoundWrapper(new com.agifans.agile.agilib.Sound(buffer.toByteArray())); + } + + public static class AgileSoundWrapper implements Sound { + + private com.agifans.agile.agilib.Sound agileSound; + + public AgileSoundWrapper(com.agifans.agile.agilib.Sound agileSound) { + this.agileSound = agileSound; + } + + public com.agifans.agile.agilib.Sound getAgileSound() { + return agileSound; + } + } +} diff --git a/core/src/main/java/com/agifans/agile/agilib/Game.java b/core/src/main/java/com/agifans/agile/agilib/Game.java new file mode 100644 index 0000000..c9751c8 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/Game.java @@ -0,0 +1,129 @@ +package com.agifans.agile.agilib; + +import java.io.File; +import java.io.IOException; + +import com.agifans.agile.agilib.AgileLogicProvider.AgileLogicWrapper; +import com.agifans.agile.agilib.AgileSoundProvider.AgileSoundWrapper; +import com.sierra.agi.res.ResourceCache; +import com.sierra.agi.res.ResourceCacheFile; +import com.sierra.agi.res.ResourceException; + +/** + * An adapter between the interface that AGILE expects and the JAGI library. + */ +public class Game { + + private ResourceCache resourceCache; + + public String gameFolder; + + public String v3GameSig; + + public String version; + + public Words words; + + public Objects objects; + + public Logic[] logics; + + public Picture[] pictures; + + public View[] views; + + public Sound[] sounds; + + /** + * Constructor for Game. + * + * @param gameFolder The folder to load the AGI game from. + */ + public Game(String gameFolder) { + this.gameFolder = gameFolder; + + // The aim is to try to use JAGI as untouched as possible to load resources + // for use in AGILE, i.e. JAGI becomes the AGI library for the Java version + // of AGILE. + + try { + // We use our own LogicProvider & SoundProvider implementations, so that we can + // load LOGICs and SOUNDs directly in the form required by AGILE. The other + // types are converted from the JAGI types, after being loaded. It didn't make + // sense to do that for the Logic and Sound types, as it is quite different. Luckily + // JAGI already provided a way to plug in a custom implementations via properties. + System.setProperty("com.sierra.agi.logic.LogicProvider", "com.agifans.agile.agilib.AgileLogicProvider"); + System.setProperty("com.sierra.agi.sound.SoundProvider", "com.agifans.agile.agilib.AgileSoundProvider"); + + // Use JAGI to fully load the AGI game's files. + resourceCache = new ResourceCacheFile(new File(gameFolder)); + version = resourceCache.getVersion(); + v3GameSig = resourceCache.getV3GameSig(); + logics = loadLogics(); + pictures = loadPictures(); + views = loadViews(); + sounds = loadSounds(); + objects = new Objects(resourceCache.getObjects()); + words = new Words(resourceCache.getWords()); + + } catch (ResourceException | IOException e) { + throw new RuntimeException("Decode of game failed.", e); + } + } + + private Logic[] loadLogics() { + Logic[] logics = new Logic[256]; + for (short i=0; i<256; i++) { + try { + Logic logic = ((AgileLogicWrapper)resourceCache.getLogic(i)).getAgileLogic(); + logic.index = i; + logics[i] = logic; + } catch (Exception rnee) { + // Ignore. The LOGIC doesn't exist. + } + } + return logics; + } + + private Picture[] loadPictures() { + Picture[] pictures = new Picture[256]; + for (short i=0; i<256; i++) { + try { + Picture picture = new Picture(resourceCache.getPicture(i)); + picture.index = i; + pictures[i] = picture; + } catch (Exception e) { + // Ignore. The PICTURE doesn't exist. + } + } + return pictures; + } + + private View[] loadViews() { + View[] views = new View[256]; + for (short i=0; i<256; i++) { + try { + View view = new View(resourceCache.getView(i)); + view.index = i; + views[i] = view; + } catch (Exception e) { + // Ignore. The VIEW doesn't exist. + } + } + return views; + } + + private Sound[] loadSounds() { + Sound[] sounds = new Sound[256]; + for (short i=0; i<256; i++) { + try { + Sound sound = ((AgileSoundWrapper)resourceCache.getSound(i)).getAgileSound(); + sound.index = i; + sounds[i] = sound; + } catch (Exception e) { + // Ignore. The SOUND doesn't exist. + } + } + return sounds; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/agifans/agile/agilib/Logic.java b/core/src/main/java/com/agifans/agile/agilib/Logic.java new file mode 100644 index 0000000..82ba1ea --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/Logic.java @@ -0,0 +1,810 @@ +package com.agifans.agile.agilib; + +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Logic extends Resource { + + /** + * At the top level, all Instructions are Actions. The Conditions only exist as + * members of an IfAction or OrCondition. + */ + public List actions; + + /** + * Holds the messages for this Logic. + */ + public List messages; + + /** + * A Lookup mapping between an address value and the index within the Actions List + * of the Action that is at that address. + */ + public Map addressToActionIndex; + + /** + * Whether the messages are crypted or not. + */ + private boolean messagesCrypted; + + /** + * Constructor for Logic. + * + * @param rawData + * @param messagesCrypted + */ + public Logic(byte[] rawData, boolean messagesCrypted) { + // A Logic is simply a collection of Actions and a collection of Messages. + this.actions = new ArrayList<>(); + this.messages = new ArrayList<>(); + this.addressToActionIndex = new HashMap<>(); + this.messagesCrypted = messagesCrypted; + + // Decode the raw LOGIC resource data into the Actions and Messages. + decode(rawData); + } + + /** + * Decode the raw LOGIC resource data into the Actions and Messages. + * + * @param rawData + */ + public void decode(byte[] rawData) { + // Read the Instructions. The first two bytes are the length of the Instructions section. + readActions(ByteBuffer.wrap(rawData, 2, ((rawData[0] & 0xFF) + ((rawData[1] & 0xFF) << 8)))); + + // Read the messages. + readMessages(rawData); + } + + /** + * Reads all Action commands from the given Stream. + * + * @param stream The Stream to read the Actions from. + */ + private void readActions(ByteBuffer stream) { + Action action; + int actionNumber = 0; + + while ((action = readAction(stream)) != null) { + actions.add(action); + addressToActionIndex.put(action.address, actionNumber++); + } + } + + /** + * Reads an Action from the given Stream. If the end of the Stream has been reached, + * will return null. + * + * @param stream The Stream to read the Action from. + * + * @return The Action that was read in, or null if the end of the Stream was reached. + */ + private Action readAction(ByteBuffer stream) { + Action action = null; + int address = stream.position(); + int actionOpcode = readUnsignedByte(stream); + + if (actionOpcode >= 0) { + if (actionOpcode == 0xFF) { // IF + List operands = new ArrayList(); + List conditions = new ArrayList(); + Condition condition = null; + + while ((condition = readCondition(stream, 0xFF)) != null) { + conditions.add(condition); + } + + operands.add(new Operand(OperandType.TESTLIST, conditions)); + operands.add(new Operand(OperandType.ADDRESS, + ((short)(readUnsignedByte(stream) + (readUnsignedByte(stream) << 8))) + + stream.position())); + action = new IfAction(operands); + } + else if (actionOpcode == 0xFE) { // GOTO + List operands = new ArrayList(); + operands.add(new Operand(OperandType.ADDRESS, + ((short)(readUnsignedByte(stream) + (readUnsignedByte(stream) << 8))) + + stream.position())); + action = new GotoAction(operands); + } + else { + // Otherwise it is a normal Action. + Operation operation = ACTION_OPERATIONS[actionOpcode]; + List operands = new ArrayList(); + + for (OperandType operandType : operation.operandTypes) { + operands.add(new Operand(operandType, readUnsignedByte(stream))); + } + + action = new Action(operation, operands); + } + + // Keep track of each Instruction's address and Logic as we read them in. + action.address = address; + action.logic = this; + } + + return action; + } + + /** + * Reads a Condition from the given Stream. If the first byte read matches the endCode, then + * we return null to indicate that there is no Condition to read. Conditions always appear + * within an IF block or an OR block, so the endCode will be either 0xFF (for if) or 0xFC (for + * or). + * + * @param stream The Stream to read from. + * @param endCode The code that we return null for. Will be either 0xFF or 0xFC. + * + * @return The Condition that was read if there was one to read; otherwise null if there wasn't one. + */ + private Condition readCondition(ByteBuffer stream, int endCode) { + Condition condition = null; + int address = (int)stream.position() - 2; + int conditionOpcode = readUnsignedByte(stream); + + if (conditionOpcode != endCode) { + if (conditionOpcode == 0xFC) { // OR + List operands = new ArrayList(); + List conditions = new ArrayList(); + Condition orCondition = null; + + while ((orCondition = readCondition(stream, 0xFC)) != null) { + conditions.add(orCondition); + } + + operands.add(new Operand(OperandType.TESTLIST, conditions)); + condition = new OrCondition(operands); + } + else if (conditionOpcode == 0xFD) { // NOT + List operands = new ArrayList(); + operands.add(new Operand(OperandType.TEST, readCondition(stream, 0xFF))); + condition = new NotCondition(operands); + } + else if (conditionOpcode == 0x0E) { // SAID + // The said command has a variable number of 16 bit word numbers, so needs special handling. + Operation operation = TEST_OPERATIONS[conditionOpcode]; + List operands = new ArrayList(); + List wordNumbers = new ArrayList(); + int numOfWords = readUnsignedByte(stream); + + for (int i=0; i < numOfWords; i++) { + wordNumbers.add(readUnsignedByte(stream) + (readUnsignedByte(stream) << 8)); + } + + operands.add(new Operand(OperandType.WORDLIST, wordNumbers)); + condition = new Condition(operation, operands); + } + else { + // Otherwise it's a normal condition. + Operation operation = TEST_OPERATIONS[conditionOpcode]; + List operands = new ArrayList(); + + for (OperandType operandType : operation.operandTypes) { // TODO: This is where the null reference is. + operands.add(new Operand(operandType, readUnsignedByte(stream))); + } + + condition = new Condition(operation, operands); + } + + // Keep track of each Instruction's address and Logic as we read them in. + condition.address = address; + condition.logic = this; + } + + return condition; + } + + /** + * Reads the Logic's messages from the raw data. + * + * @param rawData The raw data to read the messages from. + */ + private void readMessages(byte[] rawData) { + int messagesOffset = ((rawData[0] & 0xFF) + ((rawData[1] & 0xFF) << 8)) + 2; + int numOfMessages = (rawData[messagesOffset + 0] & 0xFF); + int startOfText = messagesOffset + 3 + (numOfMessages * 2); + + if (messagesCrypted) { + // Decrypt the message text section. + crypt(rawData, startOfText, rawData.length); + } + + // Message numbers start at 1, so we'll set index 0 to empty. + this.messages.add(""); + + // Add each message to the Messages List. + for (int messNum = 1, marker = messagesOffset + 3; messNum <= numOfMessages; messNum++, marker += 2) { + // Calculate the start of this message text. + int msgStart = (rawData[marker] & 0xFF) + ((rawData[marker + 1] & 0xFF) << 8); + String msgText = ""; + + // Message text will only exist for those where the start offset is greater than 0. + if (msgStart > 0) { + int msgEnd = (msgStart += (messagesOffset + 1)); + + // Find the end of the message text. It is 0 terminated. + while ((rawData[msgEnd++] & 0xFF) != 0) ; + + // Convert the byte data between the message start and end in to an ASCII string. + msgText = new String(rawData, msgStart, msgEnd - msgStart - 1, Charset.forName("Cp437")); + } + + this.messages.add(msgText); + } + } + + /** + * Reads a byte from the ByteBuffer then converts to unsigned int. + * + * @param byteBuffer ByteBuffer to read the byte from. + * + * @return An int containing the unsigned byte value, or -1 if end reached. + */ + private int readUnsignedByte(ByteBuffer byteBuffer) { + try { + return ((int)byteBuffer.get() & 0xFF); + } catch (BufferUnderflowException e) { + return -1; + } + } + + /** + * Represents an AGI Instruction, being an Operation and it's List of Operands. This class + * is abstract since all Instructions will be either an Action or a Condition. + */ + public abstract class Instruction { + + /** + * The Operation for this Instruction. + */ + public Operation operation; + + /** + * The List of Operands for this Instruction. + */ + public List operands; + + /** + * The address of this Instruction within the Logic file. + */ + public int address; + + /** + * Holds a reference to the Logic that this Instruction belongs to. + */ + public Logic logic; + + /** + * Constructor for Instruction. + * + * @param operation The Operation for this Instruction. + * @param operands The List of Operands for this Instruction. + */ + public Instruction(Operation operation, List operands) { + this.operation = operation; + this.operands = operands; + } + + public String toString() { + StringBuilder str = new StringBuilder(); + if (logic != null) { + str.append("LOGIC."); + str.append(logic.index); + str.append(": Address "); + str.append(address); + str.append(": "); + } + str.append(operation.format); + str.append(" ] "); + for (Operand operand : operands) { + str.append(operand.getValue().toString()); + str.append(", "); + } + return str.toString(); + } + } + + /** + * A Condition is a type of AGI Instruction that tests something and returns + * a boolean value. + */ + public class Condition extends Instruction { + public Condition(Operation operation, List operands) { + super(operation, operands); + } + } + + /** + * An Action is a type of AGI Instruction that performs an action. + */ + public class Action extends Instruction { + + public Action(Operation operation, List operands) { + super(operation, operands); + } + + /** + * Get the index of this Action within it's Logic's Action List. + */ + public int getActionNumber() { + return this.logic.addressToActionIndex.get(this.address); + } + } + + /** + * The JumpAction is an abstract base class of both the IfAction and GotoAction. + */ + public abstract class JumpAction extends Action { + + public JumpAction(Operation operation, List operands) { + super(operation, operands); + } + + /** + * Gets the index of the Action that this JumpAction jumps to. + * + * @return The index of the Action that this JumpAction jumps to. + */ + public int getDestinationActionIndex() { + return this.logic.addressToActionIndex.get(this.getDestinationAddress()); + } + + /** + * Gets the destination address of this JumpAction. + * + * @return The destination address of this JumpAction. + */ + public abstract int getDestinationAddress(); + } + + /** + * The IfAction is a special type of AGI Instruction that tests one or more Conditions + * to decide whether to jump over the block of immediately following Actions. It's operands + * are a List of Conditions and a jump address. + */ + public class IfAction extends JumpAction { + + public IfAction(List operands) { + super(new Operation(255, "if(TESTLIST,ADDRESS)", "InstructionIf"), operands); + } + + public int getDestinationAddress() { + return this.operands.get(1).asInt(); + } + } + + /** + * The GotoAction is a special type of AGI Instruction that performs an unconditional + * jump to a given address. It's one and only operand is the jump address. This Instruction + * is mainly used for the 'else' keyword, but also for the 'goto' keyword. + */ + public class GotoAction extends JumpAction { + + public GotoAction(List operands) { + super(new Operation(254, "goto(ADDRESS)", "InstructionGoto"), operands); + } + + public int getDestinationAddress() { + return this.operands.get(0).asInt(); + } + } + + /** + * The NotCondition is a special type of AGI Instruction that tests that the test + * command immediately following it evaluates to false. It's one and only operand will + * be a Condition, and that Condition cannot be an OrCondition. + */ + public class NotCondition extends Condition { + + public NotCondition(List operands) { + super(new Operation(253, "not(TEST)", "ExpressionNot"), operands); + } + } + + /** + * The OrCondition is a special type of AGI Instruction that tests two or more + * Conditions to see if at least one of them evaluates to true. It's operand is + * a List of Conditions. + */ + public class OrCondition extends Condition { + + public OrCondition(List operands) { + super(new Operation(252, "or(TESTLIST)", "ExpressionOr"), operands); + } + } + + /** + * An Instruction usually has one or more Operands, although there are some that don't. An + * Operand is of a particular OperandType and has a Value. + */ + public class Operand { + + public OperandType operandType; + + private Object value; + + /** + * Constructor for Operand. + * + * @param operandType The OperandType for this Operand. + * @param value The value for this Operand. + */ + public Operand(OperandType operandType, Object value) + { + this.operandType = operandType; + this.value = value; + } + + /** + * Gets the Operand's value as an int. + */ + public int asInt() { + return ((Number)value).intValue(); + } + + /** + * Gets the Operand's value as a short. + */ + public short asShort() { + return ((Number)value).shortValue(); + } + + /** + * Gets the Operand's value as unsigned byte. + */ + public int asByte() { + return (int)(((Number)value).intValue() & 0xFF); + } + + /** + * Gets the Operand's value as a signed byte. + */ + public byte asSByte() { + return ((Number)value).byteValue(); + } + + /** + * Gets the Operand's value as a Condition. + */ + public Condition asCondition() { + return (Condition)value; + } + + /** + * Gets the Operand's value as a List of Conditions. + */ + @SuppressWarnings("unchecked") + public List asConditions() { + return (List)value; + } + + @SuppressWarnings("unchecked") + public List asInts() { + return (List)value; + } + + public Object getValue() { + return value; + } + } + + /** + * The different types of Operand that the AGI Action and Condition instructions can have. + */ + public enum OperandType { + VAR, + NUM, + FLAG, + OBJECT, + WORDLIST, + VIEW, + MSGNUM, + TEST, + TESTLIST, + ADDRESS + } + + /** + * The Operation class represents an AGI command, e.g. the add operation, or isset + * operation. The distinction between the Operation class and the Instruction classes + * is that an Operation instance holds information about the AGI command, whereas the + * Instruction classes hold information about an instance of the usage of an AGI + * command. So the Operation instances are essentially reference data that is referenced + * by the Instructions. Multiple Instruction instances can and will refer to the same + * Operation. + */ + public static class Operation { + + /** + * The AGI opcode or bytecode value for this Operation. + */ + public int opcode; + + /** + * A format string that describes the name and arguments for this Operation. + */ + public String format; + + /** + * The name of this Operation, e.g. set.view + */ + public String name; + + /** + * The List of OperandTypes for this Operation. + */ + public List operandTypes; + + /** + * The name of the interpreter class that executes this Operation. + */ + public String executionClass; + + /** + * Constructor for Operation. + * + * @param opcode The AGI opcode or bytecode value for this Operation. + * @param format A format string that describes the name and arguments for this Operation. + * @param executionClass The name of the interpreter class that executes this Operation. + */ + public Operation(int opcode, String format, String executionClass) { + this.opcode = opcode; + this.format = format; + this.executionClass = executionClass; + this.operandTypes = new ArrayList(); + + // Work out the position of the two brackets in the format string. + int openBracket = format.indexOf("("); + int closeBracket = format.indexOf(")"); + + // The Name is the bit before the open bracket. + this.name = format.substring(0, openBracket); + + // If the brackets are not next to each other, the operation has operands. + if ((closeBracket - openBracket) > 1) { + String operandsStr = format.substring(openBracket + 1, closeBracket); + + for (String operandTypeStr : operandsStr.split(",")) { + OperandType operandType = OperandType.valueOf(operandTypeStr); + this.operandTypes.add(operandType); + } + } + } + } + + /** + * Static array of the AGI TEST Operations. + */ + private static Operation[] TEST_OPERATIONS = new Operation[] + { + null, + new Operation(1, "equaln(VAR,NUM)", "ExpressionEqual"), + new Operation(2, "equalv(VAR,VAR)", "ExpressionEqualV"), + new Operation(3, "lessn(VAR,NUM)", "ExpressionLess"), + new Operation(4, "lessv(VAR,VAR)", "ExpressionLessV"), + new Operation(5, "greatern(VAR,NUM)", "ExpressionGreater"), + new Operation(6, "greaterv(VAR,VAR)", "ExpressionGreaterV"), + new Operation(7, "isset(FLAG)", "ExpressionIsSet"), + new Operation(8, "isset.v(VAR)", "ExpressionIsSetV"), + new Operation(9, "has(OBJECT)", "ExpressionHas"), + new Operation(10, "obj.in.room(OBJECT,VAR)", "ExpressionObjInRoom"), + new Operation(11, "posn(OBJECT,NUM,NUM,NUM,NUM)", "ExpressionPosN"), + new Operation(12, "controller(NUM)", "ExpressionController"), + new Operation(13, "have.key()", "ExpressionHaveKey"), + new Operation(14, "said(WORDLIST)", "ExpressionSaid"), + new Operation(15, "compare.strings(NUM,NUM)", "ExpressionStringCompare"), + new Operation(16, "obj.in.box(OBJECT,NUM,NUM,NUM,NUM)", "ExpressionObjInBox"), + new Operation(17, "center.posn(OBJECT,NUM,NUM,NUM,NUM)", "ExpressionCentrePosition"), + new Operation(18, "right.posn(OBJECT,NUM,NUM,NUM,NUM)", "ExpressionRightPosition") + }; + + /** + * Adjusts the AGI command definitions to match the given AGI version. + * + * @param version The AGI version to adjust the command definitions to match. + */ + public static void AdjustCommandsForVersion(String version) + { + if (version.equals("2.089")) { + ACTION_OPERATIONS[134] = new Operation(134, "quit()", "InstructionQuit"); + } + } + + /** + * Static array of the AGI ACTION Operations. + */ + private static Operation[] ACTION_OPERATIONS = new Operation[] { + new Operation(0, "return()", "InstructionReturn"), + new Operation(1, "increment(VAR)", "InstructionIncrement"), + new Operation(2, "decrement(VAR)", "InstructionDecrement"), + new Operation(3, "assignn(VAR,NUM)", "InstructionAssign"), + new Operation(4, "assignv(VAR,VAR)", "InstructionAssignV"), + new Operation(5, "addn(VAR,NUM)", "InstructionAdd"), + new Operation(6, "addv(VAR,VAR)", "InstructionAddV"), + new Operation(7, "subn(VAR,NUM)", "InstructionSubstract"), + new Operation(8, "subv(VAR,VAR)", "InstructionSubstractV"), + new Operation(9, "lindirectv(VAR,VAR)", "InstructionIndirect"), + new Operation(10, "rindirect(VAR,VAR)", "InstructionIndirect"), + new Operation(11, "lindirectn(VAR,NUM)", "InstructionIndirect"), + new Operation(12, "set(FLAG)", "InstructionSet"), + new Operation(13, "reset(FLAG)", "InstructionReset"), + new Operation(14, "toggle(FLAG)", "InstructionToggle"), + new Operation(15, "set.v(VAR)", "InstructionSet"), + new Operation(16, "reset.v(VAR)", "InstructionReset"), + new Operation(17, "toggle.v(VAR)", "InstructionToggle"), + new Operation(18, "new.room(NUM)", "InstructionNewRoom"), + new Operation(19, "new.room.f(VAR)", "InstructionNewRoomV"), + new Operation(20, "load.logics(NUM)", "InstructionLoadLogic"), + new Operation(21, "load.logics.f(VAR)", "InstructionLoadLogicV"), + new Operation(22, "call(NUM)", "InstructionCall"), + new Operation(23, "call.f(VAR)", "InstructionCallV"), + new Operation(24, "load.pic(VAR)", "InstructionLoadPic"), + new Operation(25, "draw.pic(VAR)", "InstructionDrawPic"), + new Operation(26, "show.pic()", "InstructionShowPic"), + new Operation(27, "discard.pic(VAR)", "InstructionDiscardPic"), + new Operation(28, "overlay.pic(VAR)", "InstructionOverlayPic"), + new Operation(29, "show.pri.screen()", "InstructionShowPriScreen"), + new Operation(30, "load.view(VIEW)", "InstructionLoadView"), + new Operation(31, "load.view.f(VAR)", "InstructionLoadViewV"), + new Operation(32, "discard.view(VIEW)", "InstructionDiscardView"), + new Operation(33, "animate.obj(OBJECT)", "InstructionAnimateObject"), + new Operation(34, "unanimate.all()", "InstructionUnanimateAll"), + new Operation(35, "draw(OBJECT)", "InstructionDraw"), + new Operation(36, "erase(OBJECT)", "InstructionErase"), + new Operation(37, "position(OBJECT,NUM,NUM)", "InstructionPosition"), + new Operation(38, "position.f(OBJECT,VAR,VAR)", "InstructionPositionV"), + new Operation(39, "get.posn(OBJECT,VAR,VAR)", "InstructionGetPosition"), + new Operation(40, "reposition(OBJECT,VAR,VAR)", "InstructionReposition"), + new Operation(41, "set.view(OBJECT,VIEW)", "InstructionSetView"), + new Operation(42, "set.view.f(OBJECT,VAR)", "InstructionSetViewV"), + new Operation(43, "set.loop(OBJECT,NUM)", "InstructionSetLoop"), + new Operation(44, "set.loop.f(OBJECT,VAR)", "InstructionSetLoopV"), + new Operation(45, "fix.loop(OBJECT)", "InstructionFixLoop"), + new Operation(46, "release.loop(OBJECT)", "InstructionReleaseLoop"), + new Operation(47, "set.cel(OBJECT,NUM)", "InstructionSetCell"), + new Operation(48, "set.cel.f(OBJECT,VAR)", "InstructionSetCellV"), + new Operation(49, "last.cel(OBJECT,VAR)", "InstructionLastCell"), + new Operation(50, "current.cel(OBJECT,VAR)", "InstructionCurrentCell"), + new Operation(51, "current.loop(OBJECT,VAR)", "InstructionCurrentLoop"), + new Operation(52, "current.view(OBJECT,VAR)", "InstructionCurrentView"), + new Operation(53, "number.of.loops(OBJECT,VAR)", "InstructionLastLoop"), + new Operation(54, "set.priority(OBJECT,NUM)", "InstructionSetPriority"), + new Operation(55, "set.priority.f(OBJECT,VAR)", "InstructionSetPriorityV"), + new Operation(56, "release.priority(OBJECT)", "InstructionReleasePriority"), + new Operation(57, "get.priority(OBJECT,VAR)", "InstructionGetPriority"), + new Operation(58, "stop.update(OBJECT)", "InstructionStopUpdate"), + new Operation(59, "start.update(OBJECT)", "InstructionStartUpdate"), + new Operation(60, "force.update(OBJECT)", "InstructionForceUpdate"), + new Operation(61, "ignore.horizon(OBJECT)", "InstructionIgnoreHorizon"), + new Operation(62, "observe.horizon(OBJECT)", "InstructionObserveHorizon"), + new Operation(63, "set.horizon(NUM)", "InstructionSetHorizon"), + new Operation(64, "object.on.water(OBJECT)", "InstructionObjectOnWater"), + new Operation(65, "object.on.land(OBJECT)", "InstructionObjectOnLand"), + new Operation(66, "object.on.anything(OBJECT)", "InstructionObjectOnAnything"), + new Operation(67, "ignore.objs(OBJECT)", "InstructionIgnoreObjects"), + new Operation(68, "observe.objs(OBJECT)", "InstructionObserveObjects"), + new Operation(69, "distance(OBJECT,OBJECT,VAR)", "InstructionDistance"), + new Operation(70, "stop.cycling(OBJECT)", "InstructionStopCycling"), + new Operation(71, "start.cycling(OBJECT)", "InstructionStartCycling"), + new Operation(72, "normal.cycle(OBJECT)", "InstructionNormalCycling"), + new Operation(73, "end.of.loop(OBJECT,FLAG)", "InstructionEndOfLoop"), + new Operation(74, "reverse.cycle(OBJECT)", "InstructionReverseCycling"), + new Operation(75, "reverse.loop(OBJECT,FLAG)", "InstructionReverseLoop"), + new Operation(76, "cycle.time(OBJECT,VAR)", "InstructionCycleTime"), + new Operation(77, "stop.motion(OBJECT)", "InstructionStopMotion"), + new Operation(78, "start.motion(OBJECT)", "InstructionStartMotion"), + new Operation(79, "step.size(OBJECT,VAR)", "InstructionStepSize"), + new Operation(80, "step.time(OBJECT,VAR)", "InstructionStepTime"), + new Operation(81, "move.obj(OBJECT,NUM,NUM,NUM,FLAG)", "InstructionMoveObject"), + new Operation(82, "move.obj.f(OBJECT,VAR,VAR,VAR,FLAG)", "InstructionMoveObjectV"), + new Operation(83, "follow.ego(OBJECT,NUM,FLAG)", "InstructionFollowEgo"), + new Operation(84, "wander(OBJECT)", "InstructionWander"), + new Operation(85, "normal.motion(OBJECT)", "InstructionNormalMotion"), + new Operation(86, "set.dir(OBJECT,VAR)", "InstructionSetDir"), + new Operation(87, "get.dir(OBJECT,VAR)", "InstructionGetDir"), + new Operation(88, "ignore.blocks(OBJECT)", "InstructionIgnoreBlocks"), + new Operation(89, "observe.blocks(OBJECT)", "InstructionObserveBlocks"), + new Operation(90, "block(NUM,NUM,NUM,NUM)", "InstructionBlock"), + new Operation(91, "unblock()", "InstructionUnblock"), + new Operation(92, "get(OBJECT)", "InstructionGet"), + new Operation(93, "get.f(VAR)", "InstructionGetV"), + new Operation(94, "drop(OBJECT)", "InstructionDrop"), + new Operation(95, "put(OBJECT,VAR)", "InstructionPut"), + new Operation(96, "put.f(VAR,VAR)", "InstructionPutV"), + new Operation(97, "get.room.f(VAR,VAR)", "InstructionGetRoom"), + new Operation(98, "load.sound(NUM)", "InstructionLoadSound"), + new Operation(99, "sound(NUM,FLAG)", "InstructionPlaySound"), + new Operation(100, "stop.sound()", "InstructionStopSound"), + new Operation(101, "print(MSGNUM)", "InstructionPrint"), + new Operation(102, "print.f(VAR)", "InstructionPrintV"), + new Operation(103, "display(NUM,NUM,MSGNUM)", "InstructionDisplay"), + new Operation(104, "display.f(VAR,VAR,VAR)", "InstructionDisplayV"), + new Operation(105, "clear.lines(NUM,NUM,NUM)", "InstructionClearLine"), + new Operation(106, "text.screen()", "InstructionTextScreen"), + new Operation(107, "graphics()", "InstructionGraphics"), + new Operation(108, "set.cursor.char(MSGNUM)", "InstructionSetCursorChar"), + new Operation(109, "set.text.attribute(NUM,NUM)", "InstructionSetTextAttributes"), + new Operation(110, "shake.screen(NUM)", "InstructionShakeScreen"), + new Operation(111, "configure.screen(NUM,NUM,NUM)", "InstructionConfigureScreen"), + new Operation(112, "status.line.on()", "InstructionStatusLineOn"), + new Operation(113, "status.line.off()", "InstructionStatusLineOff"), + new Operation(114, "set.string(NUM,MSGNUM)", "InstructionSetString"), + new Operation(115, "get.string(NUM,MSGNUM,NUM,NUM,NUM)", "InstructionGetString"), + new Operation(116, "word.to.string(NUM,NUM)", "InstructionWordToString"), + new Operation(117, "parse(NUM)", "InstructionParse"), + new Operation(118, "get.num(MSGNUM,VAR)", "InstructionGetNum"), + new Operation(119, "prevent.input()", "InstructionPreventInput"), + new Operation(120, "accept.input()", "InstructionAcceptInput"), + new Operation(121, "set.key(NUM,NUM,NUM)", "InstructionSetKey"), + new Operation(122, "add.to.pic(VIEW,NUM,NUM,NUM,NUM,NUM,NUM)", "InstructionAddToPic"), + new Operation(123, "add.to.pic.f(VAR,VAR,VAR,VAR,VAR,VAR,VAR)", "InstructionAddToPicV"), + new Operation(124, "status()", "InstructionStatus"), + new Operation(125, "save.game()", "InstructionSaveGame"), + new Operation(126, "restore.game()", "InstructionRestoreGame"), + new Operation(127, "init.disk()", "InstructionInitDisk"), + new Operation(128, "restart.game()", "InstructionRestartGame"), + new Operation(129, "show.obj(VIEW)", "InstructionShowObject"), + new Operation(130, "random(NUM,NUM,VAR)", "InstructionRandom"), + new Operation(131, "program.control()", "InstructionProgramControl"), + new Operation(132, "player.control()", "InstructionPlayerControl"), + new Operation(133, "obj.status.f(VAR)", "InstructionObjectStatus"), + new Operation(134, "quit(NUM)", "InstructionQuit"), // Remove parameter for AGI v2.001/v2.089 + new Operation(135, "show.mem()", "InstructionShowMem"), + new Operation(136, "pause()", "InstructionPause"), + new Operation(137, "echo.line()", "InstructionEchoLine"), + new Operation(138, "cancel.line()", "InstructionCancelLine"), + new Operation(139, "init.joy()", "InstructionInitJoystick"), + new Operation(140, "toggle.monitor()", "InstructionToggleMonitor"), + new Operation(141, "version()", "InstructionVersion"), + new Operation(142, "script.size(NUM)", "InstructionSetScriptSize"), + new Operation(143, "set.game.id(MSGNUM)", "InstructionSetGameID"), // Command is max.drawn(NUM) for AGI v2.001 + new Operation(144, "log(MSGNUM)", "InstructionLog"), + new Operation(145, "set.scan.start()", "InstructionSetScanStart"), + new Operation(146, "reset.scan.start()", "InstructionSetScanStart"), + new Operation(147, "reposition.to(OBJECT,NUM,NUM)", "InstructionPosition"), + new Operation(148, "reposition.to.f(OBJECT,VAR,VAR)", "InstructionPositionV"), + new Operation(149, "trace.on()", "InstructionTraceOn"), + new Operation(150, "trace.info(NUM,NUM,NUM)", "InstructionTraceInfo"), + new Operation(151, "print.at(MSGNUM,NUM,NUM,NUM)", "InstructionPrintAt"), + new Operation(152, "print.at.v(VAR,NUM,NUM,NUM)", "InstructionPrintAtV"), + new Operation(153, "discard.view.v(VAR)", "InstructionDiscardView"), + new Operation(154, "clear.text.rect(NUM,NUM,NUM,NUM,NUM)", "InstructionClearTextRect"), + new Operation(155, "set.upper.left(NUM,NUM)", "InstructionUpperLeft"), + new Operation(156, "set.menu(MSGNUM)", "InstructionSetMenu"), + new Operation(157, "set.menu.item(MSGNUM,NUM)", "InstructionSetMenuItem"), + new Operation(158, "submit.menu()", "InstructionSubmitMenu"), + new Operation(159, "enable.item(NUM)", "InstructionEnableItem"), + new Operation(160, "disable.item(NUM)", "InstructionDisableItem"), + new Operation(161, "menu.input()", "InstructionMenuInput"), + new Operation(162, "show.obj.v(VAR)", "InstructionShowObject"), + new Operation(163, "open.dialogue()", "InstructionOpenDialogue"), + new Operation(164, "close.dialogue()", "InstructionCloseDialogue"), + new Operation(165, "mul.n(VAR,NUM)", "InstructionMultiply"), + new Operation(166, "mul.v(VAR,VAR)", "InstructionMultiplyV"), + new Operation(167, "div.n(VAR,NUM)", "InstructionDivide"), + new Operation(168, "div.v(VAR,VAR)", "InstructionDivideV"), + new Operation(169, "close.window()", "InstructionCloseWindow"), + new Operation(170, "set.simple(NUM)", "InstructionSetSimple"), + new Operation(171, "push.script()", "InstructionPushScript"), + new Operation(172, "pop.script()", "InstructionPopScript"), + new Operation(173, "hold.key()", "InstructionHoldKey"), + new Operation(174, "set.pri.base(NUM)", "InstructionSetPriorityBase"), + new Operation(175, "discard.sound(NUM)", "InstructionDiscardSound"), + new Operation(176, "hide.mouse()", "InstructionHideMouse"), + new Operation(177, "allow.menu(NUM)", "InstructionAllowMenu"), + new Operation(178, "show.mouse()", "InstructionShowMouse"), + new Operation(179, "fence.mouse(NUM,NUM,NUM,NUM)", "InstructionFenceMouse"), + new Operation(180, "mouse.posn(VAR,VAR)", "InstructionMousePosition"), + new Operation(181, "release.key()", "InstructionReleaseKey"), + new Operation(182, "adj.ego.move.to.x.y(NUM,NUM)", "InstructionAdjustEgoMoveToXY"), + new Operation(254, "goto(ADDRESS)", "InstructionGoto"), + new Operation(255, "if(TESTLIST,ADDRESS)", "InstructionIf") + }; +} diff --git a/core/src/main/java/com/agifans/agile/agilib/Objects.java b/core/src/main/java/com/agifans/agile/agilib/Objects.java new file mode 100644 index 0000000..2a64540 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/Objects.java @@ -0,0 +1,115 @@ +package com.agifans.agile.agilib; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.sierra.agi.inv.InventoryObject; +import com.sierra.agi.inv.InventoryObjects; + +public class Objects extends Resource { + + public List objects; + + public int count() { return objects.size(); } + + public int numOfAnimatedObjects; + + public Objects(InventoryObjects jagiObjects) { + numOfAnimatedObjects = jagiObjects.getNumOfAnimatedObjects(); + objects = new ArrayList<>(); + + for (InventoryObject jagiObject : jagiObjects.getObjects()) { + objects.add(new Object(jagiObject.getName(), jagiObject.getLocation())); + } + } + + public Objects(Objects objects) { + this.numOfAnimatedObjects = objects.numOfAnimatedObjects; + this.objects = new ArrayList(); + for (Object obj : objects.objects) { + this.objects.add(new Object(obj.name, obj.room)); + } + } + + public byte[] encode() { + //MemoryStream stream = new MemoryStream(); + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + // The first two bytes point the start of the object names. + int numOfObjects = this.objects.size(); + int startOfNames = numOfObjects * 3; + stream.write(startOfNames & 0xFF); + stream.write((startOfNames >> 8) & 0xFF); + + // Number of animated objects appears next. + stream.write(this.numOfAnimatedObjects); + + // Write out the name offsets and room numbers. + Map nameToOffsetMap = new HashMap<>(); + List distinctNames = new ArrayList<>(); + int nextNameOffset = startOfNames; + for (int i=0; i < numOfObjects; i++) + { + Object o = this.objects.get(i); + int nameOffset = nextNameOffset; + if (nameToOffsetMap.containsKey(o.name)) + { + // Reuse existing name offset if the name matches one we've already seen. + nameOffset = nameToOffsetMap.get(o.name); + } + else + { + // Otherwise use a new name slot. + nameToOffsetMap.put(o.name, nameOffset); + distinctNames.add(o.name); + nextNameOffset += (o.name.length() + 1); + } + stream.write(nameOffset & 0xFF); + stream.write((nameOffset >> 8) & 0xFF); + stream.write(o.room); + } + + // Write out the distinct names. + for (String name : distinctNames) + { + for (byte b : name.getBytes(Charset.forName("Cp437"))) + { + stream.write(b); + } + stream.write(0); + } + + byte[] rawData = stream.toByteArray(); + + // Encrypt the raw data if required. + // TODO: Don't think this is required. SavedGames handles crypt. + //if (crypted) + //{ + // crypt(rawData, 0, rawData.length); + //} + + return rawData; + } + + public static class Object { + + /** + * The name of the object. + */ + public String name; + + /** + * The room in which the object first appears in the game. + */ + public int room; + + public Object(String name, int room) { + this.name = name; + this.room = room; + } + } +} diff --git a/core/src/main/java/com/agifans/agile/agilib/Picture.java b/core/src/main/java/com/agifans/agile/agilib/Picture.java new file mode 100644 index 0000000..fec45da --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/Picture.java @@ -0,0 +1,52 @@ +package com.agifans.agile.agilib; + +import com.sierra.agi.pic.PictureContext; +import com.sierra.agi.pic.PictureException; + +/** + * A wrapper around the JAGI Picture to provide the methods that AGILE needs. + */ +public class Picture extends Resource { + + private com.sierra.agi.pic.Picture jagiPicture; + + private PictureContext jagiPictureContext; + + public Picture(com.sierra.agi.pic.Picture jagiPicture) { + this.jagiPicture = jagiPicture; + this.jagiPictureContext = new PictureContext(); + } + + public Picture clone() { + // It doesn't matter that we're using the same JAGI Picture. The actual + // drawing state is in the PictureContext, which will be a different + // instance. The JAGI Picture contains only the Vector of picture codes. + return new Picture(jagiPicture); + } + + public void drawPicture() { + drawPicture(jagiPictureContext); + } + + protected void drawPicture(PictureContext jagiPictureContext) { + try { + this.jagiPicture.draw(jagiPictureContext); + } catch (PictureException pe) { + throw new RuntimeException("Failed to draw JAGI Picture.", pe); + } + } + + public void overlayPicture(Picture picture) { + picture.drawPicture(jagiPictureContext); + } + + public int[] getVisualPixels() { + // This int array is already ARGB values. + return jagiPictureContext.getPictureData(); + } + + public int[] getPriorityPixels() { + // This int array has the priority values, 0, 1, 2, 3, ... (i.e. not ARGB) + return jagiPictureContext.getPriorityData(); + } +} diff --git a/core/src/main/java/com/agifans/agile/agilib/Resource.java b/core/src/main/java/com/agifans/agile/agilib/Resource.java new file mode 100644 index 0000000..b588c19 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/Resource.java @@ -0,0 +1,28 @@ +package com.agifans.agile.agilib; + +public abstract class Resource { + + /** + * True if this resource has been "loaded" by the interpreter. + */ + public boolean isLoaded; + + public int index; + + /** + * Handles both the encrypt and decrypt operations. They're both the same, as the XOR is reversed + * if you do it a second time. + * + * @param rawData + * @param start + * @param end + */ + protected void crypt(byte[] rawData, int start, int end) { + int avisDurganPos = 0; + + for (int i = start; i < end; i++) { + rawData[i] ^= (byte)"Avis Durgan".charAt(avisDurganPos++ % 11); + } + } + +} diff --git a/core/src/main/java/com/agifans/agile/agilib/Sound.java b/core/src/main/java/com/agifans/agile/agilib/Sound.java new file mode 100644 index 0000000..9352f5c --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/Sound.java @@ -0,0 +1,96 @@ +package com.agifans.agile.agilib; + +import java.util.ArrayList; +import java.util.List; + +public class Sound extends Resource { + + /** + * The three tone channels. + */ + public List> notes; + + byte[] rawData = null; + + /** + * Constructor for Sound. + * + * @param rawData The raw encoded AGI SOUND data for this Sound. + */ + public Sound(byte[] rawData) { + this.notes = new ArrayList>(); + + for (int i=0; i < 4; i++) + { + this.notes.add(new ArrayList()); + } + + decode(rawData); + } + + public void decode(byte[] rawData) { + for (int n = 0; n < 4; n++) { + int start = (rawData[n * 2 + 0] & 0xFF) | ((rawData[n * 2 + 1] & 0xFF) << 8); + int end = (n < 3? (((rawData[n * 2 + 2] & 0xFF) | ((rawData[n * 2 + 3] & 0xFF) << 8)) - 5) : rawData.length); + + for (int pos = start; pos < end; pos += 5) { + Note note = new Note(n); + // TODO: Decide if byte array is appropriate in Java version. + byte[] noteData = new byte[5]; + noteData[0] = (byte)(pos + 0 < rawData.length ? rawData[pos + 0] : 0); + noteData[1] = (byte)(pos + 1 < rawData.length ? rawData[pos + 1] : 0); + noteData[2] = (byte)(pos + 2 < rawData.length ? rawData[pos + 2] : 0); + noteData[3] = (byte)(pos + 3 < rawData.length ? rawData[pos + 3] : 0); + noteData[4] = (byte)(pos + 4 < rawData.length ? rawData[pos + 4] : 0); + if (note.decode(noteData)) { + this.notes.get(n).add(note); + } + } + } + } + + public static class Note { + + public int voiceNum; + public int duration; + public double frequency; + public int volume; + public int origVolume; + public int frequencyCount; + public byte[] rawData = null; + + public Note(int voiceNum) { + this.voiceNum = voiceNum; + } + + public boolean decode(byte[] rawData) { + int duration = ((rawData[0] & 0xFF) | ((rawData[1] & 0xFF) << 8)); + if (duration == 0xFFFF) { + // Two 0xFF bytes in a row at this point ends the current voice. + return false; + } + else { + this.duration = duration; + this.frequencyCount = ((rawData[2] & 0x3F) << 4) + (rawData[3] & 0x0F); + this.origVolume = rawData[4] & 0x0F; + this.volume = 0x8; // Volume is set to 0 for PC version, so let's go with 8. + this.frequency = (frequencyCount > 0 ? 111860.0 / (double)frequencyCount : 0); + this.rawData = rawData; + return true; + } + } + + public byte[] encode() { + byte[] rawData = new byte[5]; + int freqdiv = (frequency == 0 ? 0 : (int)(111860 / frequency)); + // Note that the order of the first two bytes is switched around from how it is stored in an AGI SOUND. + rawData[0] = (byte)(duration & 0xFF); + rawData[1] = (byte)((duration >> 8) & 0xFF); + rawData[2] = (byte)((freqdiv >> 4) & 0x3F); + rawData[3] = (byte)(0x80 | ((voiceNum << 5) & 0x60) | (freqdiv & 0x0F)); + rawData[4] = (byte)(0x90 | ((voiceNum << 5) & 0x60) | (volume & 0x0F)); + this.rawData = rawData; + return rawData; + } + } +} diff --git a/core/src/main/java/com/agifans/agile/agilib/View.java b/core/src/main/java/com/agifans/agile/agilib/View.java new file mode 100644 index 0000000..a2c45bf --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/View.java @@ -0,0 +1,54 @@ +package com.agifans.agile.agilib; + +import java.util.ArrayList; + +public class View extends Resource { + + public ArrayList loops; + public String description; + + public View(com.sierra.agi.view.View jagiView) { + description = jagiView.getDescription(); + loops = new ArrayList<>(); + for (short loopNum = 0; loopNum < jagiView.getLoopCount(); loopNum++) { + loops.add(new Loop(jagiView.getLoop(loopNum))); + } + } + + public class Loop { + + public ArrayList cels; + + public Loop(com.sierra.agi.view.Loop jagiLoop) { + cels = new ArrayList<>(); + for (short cellNum = 0; cellNum < jagiLoop.getCellCount(); cellNum++) { + cels.add(new Cel(jagiLoop.getCell(cellNum))); + } + } + } + + public class Cel { + + private com.sierra.agi.view.Cel jagiCel; + + public Cel(com.sierra.agi.view.Cel jagiCel) { + this.jagiCel = jagiCel; + } + + public short getWidth() { + return jagiCel.getWidth(); + } + + public short getHeight() { + return jagiCel.getHeight(); + } + + public int[] getPixelData() { + return jagiCel.getPixelData(); + } + + public int getTransparentPixel() { + return jagiCel.getTransparentPixel(); + } + } +} diff --git a/core/src/main/java/com/agifans/agile/agilib/Words.java b/core/src/main/java/com/agifans/agile/agilib/Words.java new file mode 100644 index 0000000..5d50689 --- /dev/null +++ b/core/src/main/java/com/agifans/agile/agilib/Words.java @@ -0,0 +1,71 @@ +package com.agifans.agile.agilib; + +import java.util.HashMap; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * Represents the AGI WORDS.TOK file. + * + * The following word numbers have special meaning. + * + * Word# Meaning + * ----- ----------------------------------------------------------- + * 0 Words are ignored (e.g. the, at) + * 1 Anyword + * 9999 ROL(Rest Of Line) -- it does matter what the rest of the + * input list is + * ----- ----------------------------------------------------------- + * + * All other word numbers are free for use. + */ +public class Words extends Resource { + + /** + * A Map between a word's text and the word number for that word. + */ + public Map wordToNumber; + + /** + * A Map between a word number and the set of words that the word number is for (i.e. the synonym set). + */ + public Map> numberToWords; + + /** + * Constructor for Words. + * + * @param jagiWords The JAGI Words object to construct an AGILE Words object from. + */ + public Words(com.sierra.agi.word.Words jagiWords) { + this.wordToNumber = new HashMap(); + this.numberToWords = new HashMap>(); + for (com.sierra.agi.word.Word jagiWord : jagiWords.words()) { + addWord(jagiWord.number, jagiWord.text); + } + } + + /** + * Adds a new word for the given word text and word number. The word number does not need + * to be unique. When the word number is already in use, then the new word being added is + * a synonym for the existing word(s) using that word number. + * + * @param wordNum The word number for the word being added. + * @param wordText The word text for the word being added. + */ + public void addWord(int wordNum, String wordText) { + // Add a mapping from the word text to its word number. + this.wordToNumber.put(wordText, wordNum); + + // Add the word text to the set of words for the given word number. + SortedSet words; + if (this.numberToWords.containsKey(wordNum)) { + words = this.numberToWords.get(wordNum); + } + else { + words = new TreeSet(); + this.numberToWords.put(wordNum, words); + } + words.add(wordText); + } +} diff --git a/core/src/main/java/com/sierra/agi/awt/EgaUtils.java b/core/src/main/java/com/sierra/agi/awt/EgaUtils.java new file mode 100644 index 0000000..5ae1ac1 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/awt/EgaUtils.java @@ -0,0 +1,101 @@ +/* + * EgaUtil.java + * Adventure Game Interpreter AWT Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.awt; + +import java.awt.*; +import java.awt.image.ColorModel; +import java.awt.image.DataBuffer; +import java.awt.image.DirectColorModel; +import java.awt.image.IndexColorModel; + +/** + * Misc. Utilities for EGA support in Java's AWT. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public abstract class EgaUtils { + + /** + * EGA Colors Red Band + */ + protected static final byte[] r = {(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0xaa, (byte) 0xaa, (byte) 0xaa, (byte) 0xaa, (byte) 0x55, (byte) 0x55, (byte) 0x55, (byte) 0x55, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff}; + + /** + * EGA Colors Green Band + */ + protected static final byte[] g = {(byte) 0x00, (byte) 0x00, (byte) 0xaa, (byte) 0xaa, (byte) 0x00, (byte) 0x00, (byte) 0x55, (byte) 0xaa, (byte) 0x55, (byte) 0x55, (byte) 0xff, (byte) 0xff, (byte) 0x55, (byte) 0x55, (byte) 0xff, (byte) 0xff}; + + /** + * EGA Colors Blue Band + */ + protected static final byte[] b = {(byte) 0x00, (byte) 0xaa, (byte) 0x00, (byte) 0xaa, (byte) 0x00, (byte) 0xaa, (byte) 0x00, (byte) 0xaa, (byte) 0x55, (byte) 0xff, (byte) 0x55, (byte) 0xff, (byte) 0x55, (byte) 0xff, (byte) 0x55, (byte) 0xff}; + + /** + * EGA Color Model Cache + */ + protected static IndexColorModel indexModel; + + /** + * Native Color Model Cache + */ + protected static DirectColorModel nativeModel; + + /** + * Returns the ColorModel used by EGA Adapters. + *

+ * Used to convert visual resource from EGA Color Model to the + * Native Color Model. + */ + public static synchronized IndexColorModel getIndexColorModel() { + int i; + + if (indexModel == null) { + indexModel = new IndexColorModel(8, 16, r, g, b); + } + + return indexModel; + } + + /** + * Returns a ColorModel representing the nativiest ColorModel of the + * current system configuration. + *

+ * In order to reduce the number of ColorModel convertions, each visual + * resource is converted as soon as possible to this ColorModel. + */ + public static synchronized DirectColorModel getNativeColorModel() { + if (nativeModel == null) { + ColorModel model = Toolkit.getDefaultToolkit().getColorModel(); + DirectColorModel direct; + + if ((model.getTransferType() != DataBuffer.TYPE_INT) || + !(model instanceof DirectColorModel)) { + model = ColorModel.getRGBdefault(); + } + + if (model.getTransparency() != Transparency.OPAQUE) { + direct = (DirectColorModel) model; + model = new DirectColorModel( + direct.getColorSpace(), + direct.getPixelSize(), + direct.getRedMask(), + direct.getGreenMask(), + direct.getBlueMask(), + 0, + false, + DataBuffer.TYPE_INT); + } + + nativeModel = (DirectColorModel) model; + } + + return nativeModel; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/inv/InventoryObject.java b/core/src/main/java/com/sierra/agi/inv/InventoryObject.java new file mode 100644 index 0000000..b790e4f --- /dev/null +++ b/core/src/main/java/com/sierra/agi/inv/InventoryObject.java @@ -0,0 +1,32 @@ +/* + * InventoryObject.java + */ + +package com.sierra.agi.inv; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public final class InventoryObject { + /** + * Name + */ + public String name; + /** + * Location + */ + private final short location; + + public InventoryObject(short location) { + this.location = location; + } + + public String getName() { + return name; + } + + public short getLocation() { + return location; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/inv/InventoryObjects.java b/core/src/main/java/com/sierra/agi/inv/InventoryObjects.java new file mode 100644 index 0000000..a6dfe23 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/inv/InventoryObjects.java @@ -0,0 +1,242 @@ +/* + * InventoryObjects.java + */ + +package com.sierra.agi.inv; + +import com.sierra.agi.io.ByteCasterStream; +import com.sierra.agi.res.ResourceConfiguration; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; + +/** + * Stores Objects of the game. + *

+ * Object File Format
+ * The object file stores two bits of information about the inventory items used + * in an AGI game. The starting room location and the name of the inventory item. + * It also has a byte that determines the maximum number of animated objects. + *

+ * File Encryption
+ * The first obstacle to overcome is the fact that most object files are + * encrypted. I say most because some of the earlier AGI games were not, in + * which case you can skip to the next section. Those that are encrypted are done + * so with the string "Avis Durgan" (or, in case of AGDS games, "Alex Simkin"). + * The process of unencrypting the file is to simply taken every eleven bytes + * from the file and XOR each element of those eleven bytes with the corresponding + * element in the string "Avis Durgan". This sort of encryption is very easy to + * crack if you know what you are doing and is simply meant to act as a shield + * so as not to encourage cheating. In some games, however, the object names are + * clearly visible in the saved game files even when the object file is encrypted, + * so it's not a very effective shield. + *

+ * File Format
+ * + * + * + * + * + *
ByteMeaning
0-1Offset of the start of inventory item names
2Maximum number of animated objects
+ *

+ * Following the first three bytes as a section containing a three byte entry + * for each inventory item all of which conform to the following format: + *

+ * + * + * + * + * + *
ByteMeaning
0-1Offset of inventory item name i
2Starting room number for inventory item i or 255 carried
+ *

+ * Where i is the entry number starting at 0. All offsets are taken from the + * start of entry for inventory item 0 (not the start of the file). + *

+ * Then comes the textual names themselves. This is simply a list of NULL + * terminated strings. The offsets mentioned in the above section point to the + * first character in the string and the last character is the one before the + * 0x00. + * + * @author Dr. Z, Lance Ewing (Documentation) + * @version 0.00.00.01 + */ +public class InventoryObjects implements InventoryProvider { + /** + * Object list. + */ + protected InventoryObject[] objects = null; + protected int numOfAnimatedObjects; + + public InventoryObjects(ResourceConfiguration config) { + + } + + /** + * Loads the String Table from a AGI Object file. Internal Uses only. + * + * @param stream AGI Object file's Stream. + * @param offset Starting offset. + * @return Returns a Hashtable containing the strings with their offset has + * the Hash key. + * @throws IOException Caller must handle IOException from his stream. + */ + protected static Hashtable loadStringTable(InputStream stream, int offset) throws IOException { + Hashtable h = new Hashtable(64); + String o = ""; + int s = offset; + + while (true) { + int c = stream.read(); + offset++; + + if (c < 0) { + break; + } + + if (c == 0) { + h.put(Integer.valueOf(s), o); + o = ""; + s = offset; + } else { + o += (char) c; + } + } + + return h; + } + + /** + * Loads a AGI Object File from a stream. + * + * @param stream Stream where the Objects are contained. Must be a AGI + * compliant format. + * @return Returns the number of object contained in the stream. + * @throws IOException Caller must handle IOException from his stream. + */ + public InventoryObjects loadInventory(InputStream stream) throws IOException { + ByteCasterStream rawStream = new ByteCasterStream(stream); + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(rawStream.readAllBytes()); + ByteCasterStream bstream = new ByteCasterStream(byteArrayInputStream); + + /* Calculate Inventory Object Count */ + int padSize = 3; + int nobject = bstream.lohiReadUnsignedShort(); + nobject /= padSize; + + this.objects = new InventoryObject[nobject]; + int[] offsets = new int[nobject]; + + + this.numOfAnimatedObjects = bstream.readUnsignedByte(); + + int offset = 0; + + for (int i = 0; i < nobject; i++) { + offsets[i] = bstream.lohiReadUnsignedShort(); + objects[i] = new InventoryObject(bstream.readUnsignedByte()); + offset += padSize; + } + + Hashtable hash = loadStringTable(byteArrayInputStream, offset); + + for (int i = 0; i < nobject; i++) { + objects[i].name = (String) hash.get(Integer.valueOf(offsets[i])); + } + + byteArrayInputStream.close(); + rawStream.close(); + bstream.close(); + return this; + } + + /** + * Returns the number of objects contained in this object. + * + * @return Returns the number of objects. + */ + public short getCount() { + return (short) objects.length; + } + + /** + * Returns an Object contained in this object based on his index. + * + * @param index Index number of the wanted object. + * @return Returns the wanted object. + */ + public InventoryObject getObject(short index) { + return objects[index]; + } + + public void resetLocationTable(short[] locations) { + int i; + + for (i = 0; i < objects.length; i++) { + locations[i] = objects[i].getLocation(); + } + } + + public InventoryObject[] getObjects() { + return objects; + } + + public int getNumOfAnimatedObjects() { + return numOfAnimatedObjects; + } + + public byte[] encode(short[] locations) throws Exception{ + // Recreate the Item Entries + // key = object name + // value = offset + Map itemEntries = new HashMap<>(); + // We need to preserve the order of the objects in the list + List itemList = new ArrayList<>(); + + int count = objects.length; + int num = count * 3; + + int offset = num; + for (int i = 0; i < count; i++) { + InventoryObject inventoryObject = objects[i]; + if (!itemEntries.containsKey(inventoryObject.name)) { + itemEntries.put(inventoryObject.name, offset); + itemList.add(inventoryObject); + // 1 = NUL char + offset = offset + (inventoryObject.name.length() + 1); + } + } + + // Dump of the in memory OBJECT file including updates made by get, put and drop commands + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + outputStream.write((num & 0xFF)); + outputStream.write((num >> 8) & 0xFF); + outputStream.write(numOfAnimatedObjects); + + for (int i = 0; i < count; i++) { + InventoryObject invObject = objects[i]; + short location = locations[i]; + + int itemOffset = itemEntries.get(invObject.name); + outputStream.write(itemOffset & 0xFF); + outputStream.write((itemOffset >> 8) & 0xFF); + outputStream.write(location); + } + + for (InventoryObject inventoryObject : itemList) { + byte[] nameBytes = inventoryObject.getName().getBytes(); + outputStream.write(nameBytes); + outputStream.write(0); + } + + byte[] buffer = outputStream.toByteArray(); + + return buffer; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/inv/InventoryProvider.java b/core/src/main/java/com/sierra/agi/inv/InventoryProvider.java new file mode 100644 index 0000000..7dc5d7d --- /dev/null +++ b/core/src/main/java/com/sierra/agi/inv/InventoryProvider.java @@ -0,0 +1,16 @@ +/** + * InventoryProvider.java + * Adventure Game Interpreter Inventory Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.inv; + +import java.io.IOException; +import java.io.InputStream; + +public interface InventoryProvider { + InventoryObjects loadInventory(InputStream in) throws IOException; +} diff --git a/core/src/main/java/com/sierra/agi/io/ByteCaster.java b/core/src/main/java/com/sierra/agi/io/ByteCaster.java new file mode 100644 index 0000000..d5a06b1 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/ByteCaster.java @@ -0,0 +1,49 @@ +/** + * ByteCaster.java + * Adventure Game Interpreter I/O Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.io; + +/** + * Interprets byte arrays. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +abstract public class ByteCaster { + public static short hiloUnsignedByte(byte[] b, int off) { + return (short) (b[off] & 0xFF); + } + + public static int hiloUnsignedShort(byte[] b, int off) { + return ((b[off] & 0xFF) << 8) | + (b[off + 1] & 0xFF); + } + + public static long hiloUnsignedInt(byte[] b, int off) { + return ((long) (b[off] & 0xFF) << 24) | + ((b[off + 1] & 0xFF) << 16) | + ((b[off + 2] & 0xFF) << 8) | + (b[off + 3] & 0xFF); + } + + public static short lohiUnsignedByte(byte[] b, int off) { + return (short) (b[off] & 0xFF); + } + + public static int lohiUnsignedShort(byte[] b, int off) { + return ((b[off + 1] & 0xFF) << 8) | + (b[off] & 0xFF); + } + + public static long lohiUnsignedInt(byte[] b, int off) { + return ((long) (b[off + 3] & 0xFF) << 24) | + ((b[off + 2] & 0xFF) << 16) | + ((b[off + 1] & 0xFF) << 8) | + (b[off] & 0xFF); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/io/ByteCasterStream.java b/core/src/main/java/com/sierra/agi/io/ByteCasterStream.java new file mode 100644 index 0000000..3a07bf3 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/ByteCasterStream.java @@ -0,0 +1,76 @@ +/** + * ByteCasterStream.java + * Adventure Game Interpreter I/O Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.io; + +import java.io.EOFException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Interprets stream's data. + * + * @author Dr. Z + * @version 0.00.00.02 + */ +public class ByteCasterStream extends FilterInputStream { + public ByteCasterStream(InputStream in) { + super(in); + } + + public short readUnsignedByte() throws IOException { + int v = in.read(); + + if (v < 0) { + throw new EOFException(); + } + + return (short) v; + } + + public int hiloReadUnsignedShort() throws IOException { + byte[] b = new byte[2]; + + IOUtils.fill(in, b, 0, 2); + + return ((b[0] & 0xFF) << 8) | + (b[1] & 0xFF); + } + + public long hiloReadUnsignedInt() throws IOException { + byte[] b = new byte[4]; + + IOUtils.fill(in, b, 0, 4); + + return ((long) (b[0] & 0xFF) << 24) | + ((b[1] & 0xFF) << 16) | + ((b[2] & 0xFF) << 8) | + (b[3] & 0xFF); + } + + public int lohiReadUnsignedShort() throws IOException { + byte[] b = new byte[2]; + + IOUtils.fill(in, b, 0, 2); + + return ((b[1] & 0xFF) << 8) | + (b[0] & 0xFF); + } + + public long lohiReadUnsignedInt() throws IOException { + byte[] b = new byte[4]; + + IOUtils.fill(in, b, 0, 4); + + return ((long) (b[3] & 0xFF) << 24) | + ((b[2] & 0xFF) << 16) | + ((b[1] & 0xFF) << 8) | + (b[0] & 0xFF); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/io/CryptedInputStream.java b/core/src/main/java/com/sierra/agi/io/CryptedInputStream.java new file mode 100644 index 0000000..3811758 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/CryptedInputStream.java @@ -0,0 +1,114 @@ +/** + * CryptedInputStream.java + * Adventure Game Interpreter I/O Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.io; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Layer to support decryption of sierra's resources. + * (simple XOR cryption) + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class CryptedInputStream extends FilterInputStream { + /** Decryption key. */ + protected char[] key; + + /** Current offset. */ + protected int offset; + + /** Cryption begin offset. */ + protected int boffset; + + /** + * Offset of the last mark method call. + * + * @see #mark(int) + */ + protected int marked; + + /** + * Creates a new decryption layer. + * + * @param key Decryption key. + * @param stream InputStream to decrypt. + */ + public CryptedInputStream(InputStream in, String key) { + super(in); + this.key = key.toCharArray(); + } + + /** + * Creates a new decryption layer. + * + * @param key Decryption key. + * @param stream InputStream to decrypt. + */ + public CryptedInputStream(InputStream in, String key, int boffset) { + super(in); + this.boffset = boffset; + this.key = key.toCharArray(); + } + + public void mark(int readlimit) { + in.mark(readlimit); + marked = offset; + } + + public void reset() throws IOException { + in.reset(); + offset = marked; + } + + public long skip(long n) throws IOException { + long r = in.skip(n); + + offset += r; + return r; + } + + public int read() throws IOException { + int r = in.read(); + + if (r < 0) + return r; + + if (offset >= boffset) { + r ^= key[(offset - boffset) % key.length]; + } + + offset++; + return r; + } + + public int read(byte[] b, int off, int len) throws IOException { + int i, j, off2; + int r = in.read(b, off, len); + + if (r < 0) { + return r; + } + + off2 = off + len; + for (i = off; i < off2; i++) { + if (offset >= boffset) { + j = (b[i] & 0xFF); + j ^= key[(offset - boffset) % key.length]; + b[i] = (byte) j; + } + + offset++; + } + + return r; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/io/IOUtils.java b/core/src/main/java/com/sierra/agi/io/IOUtils.java new file mode 100644 index 0000000..50d1d69 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/IOUtils.java @@ -0,0 +1,50 @@ +/** + * IOUtils.java + * Adventure Game Interpreter I/O Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.io; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +public abstract class IOUtils { + public static int fill(InputStream in, byte[] b, int off, int len) throws IOException { + int c, r = 0; + + while (len != 0) { + c = in.read(b, off, len); + + if (c <= 0) { + throw new EOFException(); + } + + r += c; + off += c; + len -= c; + } + + return r; + } + + public static int skip(InputStream in, int len) throws IOException { + int c, r = 0; + + while (len != 0) { + c = (int) in.skip(len); + + if (c <= 0) { + throw new EOFException(); + } + + r += c; + len -= c; + } + + return r; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/io/LZWInputStream.java b/core/src/main/java/com/sierra/agi/io/LZWInputStream.java new file mode 100644 index 0000000..468eb35 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/LZWInputStream.java @@ -0,0 +1,232 @@ +/** + * LZWInputStream.java + * Adventure Game Interpreter I/O Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.io; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + * LZW Decompressor. This class is a Input Stream Layer + * to uncompress on-the-fly resource stream using the LZW + * algorithm used in AGI v3. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class LZWInputStream extends InputStream { + protected final static int MAX_BITS = 12; + protected final static int TABLE_SIZE = 18041; + protected final static int START_BITS = 9; + protected boolean endOfStream = false; + protected int bits; + protected int maxValues; + protected int maxCodes; + protected InputStream in; + protected byte[] appendChars = new byte[TABLE_SIZE]; + protected byte[] decodeStack = new byte[8192]; + protected int decodeStackSize = -1; + protected int[] prefixCode = new int[TABLE_SIZE]; + + protected int bitCount = 0; + protected long bitBuffer = 0; + protected int unext; + protected int unew; + protected int uold; + protected int ubits; + protected int uc; + /** Creates new LZWException */ + public LZWInputStream(InputStream in) throws IOException { + this.in = in; + ubits = setBits(START_BITS); + unext = 257; + uold = inputCode(); + uc = uold; + unew = inputCode(); + } + + protected int setBits(int value) { + if (value == MAX_BITS) { + return 1; + } + + bits = value; + maxValues = (1 << bits) - 1; + maxCodes = maxValues - 1; + return 0; + } + + protected int inputCode() throws IOException { + long b; + int r; + + long q = bitBuffer; + int s = bitCount; + + while (s <= 24) { + b = in.read(); + + if (b < 0) { + if (s == 0) { + throw new EOFException(); + } + + break; + } + + b <<= s; + q |= b; + s += 8; + } + + r = (int) (q & 0x7fff); + r %= (1 << bits); + + bitBuffer = (q >> bits); + bitCount = (s - bits); + + return r; + } + + protected int decodeString(int offset, int code) throws IOException { + int i; + + for (i = 0; code > 255; ) { + decodeStack[offset] = appendChars[code]; + offset++; + code = prefixCode[code]; + + if (i++ >= 4000) { + throw new IOException("LZW: Error in Code Expansion"); + } + } + + decodeStack[offset] = (byte) code; + return offset; + } + + protected void unpack() throws IOException { + if (endOfStream) { + return; + } + + if (decodeStackSize > 0) { + return; + } + + if (unew == 0x101) { + endOfStream = true; + return; + } + + if (unew == 0x100) { + unext = 258; + ubits = setBits(START_BITS); + uold = inputCode(); + uc = uold; + + decodeStack[0] = (byte) uc; + decodeStackSize = 0; + + unew = inputCode(); + } else { + if (unew >= unext) { + decodeStack[0] = (byte) uc; + decodeStackSize = decodeString(1, uold); + } else { + decodeStackSize = decodeString(0, unew); + } + + uc = decodeStack[decodeStackSize]; + + if (unext > maxCodes) { + ubits = setBits(bits + 1); + } + + prefixCode[unext] = uold; + appendChars[unext] = (byte) uc; + + unext++; + uold = unew; + + unew = inputCode(); + } + } + + public int read() throws IOException { + int c; + + while (decodeStackSize < 0) { + try { + unpack(); + } catch (EOFException eex) { + endOfStream = true; + } + + if (endOfStream) { + close(); + return -1; + } + } + + c = decodeStack[decodeStackSize]; + decodeStackSize--; + + return c; + } + + public int read(byte[] b, int off, int len) throws IOException { + int c = 0; + + while (!endOfStream) { + if (decodeStackSize >= 0) { + while ((decodeStackSize >= 0) && (len > 0)) { + b[off] = decodeStack[decodeStackSize]; + decodeStackSize--; + off++; + len--; + c++; + } + + if (len == 0) { + break; + } + } + + try { + unpack(); + } catch (EOFException eex) { + endOfStream = true; + } + } + + if (endOfStream) { + close(); + } + + if (c == 0) + c = -1; + + return c; + } + + public void close() throws IOException { + endOfStream = true; + + if (in != null) { + in.close(); + } + + /** Garbage Collector Optimization */ + in = null; + appendChars = null; + decodeStack = null; + prefixCode = null; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/io/LittleEndianOutputStream.java b/core/src/main/java/com/sierra/agi/io/LittleEndianOutputStream.java new file mode 100644 index 0000000..e71f5b8 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/LittleEndianOutputStream.java @@ -0,0 +1,137 @@ +package com.sierra.agi.io; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UTFDataFormatException; + +public class LittleEndianOutputStream extends FilterOutputStream { + protected int written; + + public LittleEndianOutputStream(OutputStream out) { + super(out); + } + + public void write(int b) throws IOException { + out.write(b); + written++; + } + + public void write(byte[] data, int offset, int length) + throws IOException { + out.write(data, offset, length); + written += length; + } + + public void writeBoolean(boolean b) throws IOException { + if (b) this.write(1); + else this.write(0); + } + + public void writeByte(int b) throws IOException { + out.write(b); + written++; + } + + public void writeShort(int s) throws IOException { + out.write(s & 0xFF); + out.write((s >>> 8) & 0xFF); + written += 2; + } + + public void writeChar(int c) throws IOException { + out.write(c & 0xFF); + out.write((c >>> 8) & 0xFF); + written += 2; + } + + public void writeInt(int i) throws IOException { + + out.write(i & 0xFF); + out.write((i >>> 8) & 0xFF); + out.write((i >>> 16) & 0xFF); + out.write((i >>> 24) & 0xFF); + written += 4; + + } + + public void writeLong(long l) throws IOException { + + out.write((int) l & 0xFF); + out.write((int) (l >>> 8) & 0xFF); + out.write((int) (l >>> 16) & 0xFF); + out.write((int) (l >>> 24) & 0xFF); + out.write((int) (l >>> 32) & 0xFF); + out.write((int) (l >>> 40) & 0xFF); + out.write((int) (l >>> 48) & 0xFF); + out.write((int) (l >>> 56) & 0xFF); + written += 8; + + } + + public final void writeFloat(float f) throws IOException { + this.writeInt(Float.floatToIntBits(f)); + } + + public final void writeDouble(double d) throws IOException { + this.writeLong(Double.doubleToLongBits(d)); + } + + public void writeBytes(String s) throws IOException { + int length = s.length(); + for (int i = 0; i < length; i++) { + out.write((byte) s.charAt(i)); + } + written += length; + } + + public void writeChars(String s) throws IOException { + int length = s.length(); + for (int i = 0; i < length; i++) { + int c = s.charAt(i); + out.write(c & 0xFF); + out.write((c >>> 8) & 0xFF); + } + written += length * 2; + } + + public void writeUTF(String s) throws IOException { + + int numchars = s.length(); + int numbytes = 0; + + for (int i = 0; i < numchars; i++) { + int c = s.charAt(i); + if ((c >= 0x0001) && (c <= 0x007F)) numbytes++; + else if (c > 0x07FF) numbytes += 3; + else numbytes += 2; + } + + if (numbytes > 65535) throw new UTFDataFormatException(); + + out.write((numbytes >>> 8) & 0xFF); + out.write(numbytes & 0xFF); + for (int i = 0; i < numchars; i++) { + int c = s.charAt(i); + if ((c >= 0x0001) && (c <= 0x007F)) { + out.write(c); + } else if (c > 0x07FF) { + out.write(0xE0 | ((c >> 12) & 0x0F)); + out.write(0x80 | ((c >> 6) & 0x3F)); + out.write(0x80 | (c & 0x3F)); + written += 2; + } else { + out.write(0xC0 | ((c >> 6) & 0x1F)); + out.write(0x80 | (c & 0x3F)); + written += 1; + } + } + + written += numchars + 2; + + } + + public int size() { + return this.written; + } +} diff --git a/core/src/main/java/com/sierra/agi/io/PictureInputStream.java b/core/src/main/java/com/sierra/agi/io/PictureInputStream.java new file mode 100644 index 0000000..9f175c0 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/PictureInputStream.java @@ -0,0 +1,117 @@ +/** + * PictureInputStream.java + * Adventure Game Interpreter I/O Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.io; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Picture Input Stream. + *

+ * Pictures in AGI version 3 use a simple form of compression to shrink their + * size by a tiny amount. It was obviously recognised by the interpreter coders + * that four bits were being wasted for picture codes 0xF0 and + * 0xF2. These are the two codes that change the visual and the + * priority colour respectively. Since there are only 16 colours, there need + * not be a whole byte set aside for storing the colour. All the picture + * compression does is store these colours in 4 bits rather than 8. + *

+ * Example:
+ * Original picture codes: F0 06 F8 12 45 F0 07 F2 05 F8 14 67 ...
+ * Compressed picture code: F0 6F 81 24 5F 07 F2 5F 81 46 7 ... + *

+ * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class PictureInputStream extends FilterInputStream { + /** Previous Byte */ + protected int previous; + + /** Current Byte */ + protected int current; + + protected int mode = 0; + + /** + * Creates new Picture Input Stream + */ + public PictureInputStream(InputStream in) { + super(in); + } + + public int read(byte[] b, int off, int len) throws IOException { + int t = 0; + int n; + + while (len > 0) { + n = read(); + + if (n < 0) { + break; + } + } + + return t; + } + + public int read() throws IOException { + int x = 0, y; + + if (in == null) { + return -1; + } + + if (mode <= 1) { + current = in.read(); + + if (mode == 0) { + x = current; + } else { + x = (current & 0xf0); + x >>= 4; + y = (previous & 0x0f); + y <<= 4; + x |= y; + } + + if (x == 0xff) { + close(); + return -1; + } + + if (x == 0xf0 || x == 0xf2) { + if (mode == 1) { + mode = 2; + } else { + mode = 3; + } + } + } else if (mode == 2) { + mode = 0; + return current & 0x0f; + } else if (mode == 3) { + mode = 1; + current = in.read(); + x = current & 0xf0; + x >>= 4; + } + + previous = current; + return x; + } + + public void close() throws IOException { + if (in != null) { + in.close(); + in = null; + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/io/PublicByteArrayInputStream.java b/core/src/main/java/com/sierra/agi/io/PublicByteArrayInputStream.java new file mode 100644 index 0000000..6a4f47b --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/PublicByteArrayInputStream.java @@ -0,0 +1,50 @@ +package com.sierra.agi.io; + +import java.io.ByteArrayInputStream; + +/** + * A sub-class of ByteArrayInputStream that makes public some of the internal state + * of its super class, such as the count and pos. + * + * @author Lance Ewing + */ +public class PublicByteArrayInputStream extends ByteArrayInputStream { + + /** + * Constructor for PublicByteArrayInputStream. + * + * @param buf The byte array from which the InputStream is to be created. + */ + public PublicByteArrayInputStream(byte[] buf) { + super(buf); + } + + /** + * Constructor for PublicByteArrayInputStream. + * + * @param buf The input buffer. + * @param offset The offset in the buffer of the first byte to read. + * @param length The maximum number of bytes to read from the buffer. + */ + public PublicByteArrayInputStream(byte[] buf, int offset, int length) { + super(buf, offset, length); + } + + /** + * Gets the current position within the byte array. + * + * @return The current position within the byte array. + */ + public int getPosition() { + return this.pos; + } + + /** + * Gets the number of bytes in the byte array. + * + * @return The number of bytes in the byte array. + */ + public int getCount() { + return this.count; + } +} diff --git a/core/src/main/java/com/sierra/agi/io/SegmentedInputStream.java b/core/src/main/java/com/sierra/agi/io/SegmentedInputStream.java new file mode 100644 index 0000000..43ccf32 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/io/SegmentedInputStream.java @@ -0,0 +1,176 @@ +/** + * SegmentedInputStream.java + * Adventure Game Interpreter I/O Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.io; + +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; + +/** + * Implentation of InputStream that gives access + * to a part of a file. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class SegmentedInputStream extends InputStream { + /** File pointer to the opened volume file. */ + protected RandomAccessFile randomFile; + + /** Current offset in the volume file. */ + protected int offset; + + /** Length of the resource data remaining. */ + protected int length; + + /** + * Offset of the last call to mark. + * + * @see #mark(int) + */ + protected int marked; + + /** + * Creates a new SegmentedInputStream. + * + * @param pEntry Pointer to a entry in the directory table. + * @throws IOException Can throw IOException. + */ + public SegmentedInputStream(RandomAccessFile file, int offset, int length) throws IOException { + this.offset = offset; + this.length = length; + this.randomFile = file; + this.marked = offset; + + randomFile.seek(offset); + } + + public int available() { + return length; + } + + public void close() throws IOException { + randomFile.close(); + randomFile = null; + } + + public void mark(int readlimit) { + marked = offset; + } + + public void reset() throws IOException { + int l = offset - marked; + + offset = marked; + length -= l; + + randomFile.seek(offset); + } + + public int diff() { + return offset - marked; + } + + public int getOffset() { + return offset; + } + + public RandomAccessFile getRandomAccessFile() { + return randomFile; + } + + /** + * Tests if this input stream supports the mark + * and reset methods. + *

+ * In this implentation of InputStream, it always + * returns true. + * + * @see #mark(int) + * @see #reset() + * @see java.io.InputStream + * @return Returns true + */ + public boolean markSupported() { + return true; + } + + public int read() throws IOException { + int r; + + if (length <= 0) + return -1; + + r = randomFile.read(); + + if (r >= 0) { + offset++; + length--; + } + + return r; + } + + public int read(byte[] b) throws IOException { + int r, l = b.length; + + if (length <= 0) { + return -1; + } + + if (l > length) { + l = length; + } + + r = randomFile.read(b, 0, l); + + if (r > 0) { + offset += r; + length -= r; + } + + return r; + } + + public int read(byte[] b, int off, int len) throws IOException { + int r; + + if (length <= 0) { + return -1; + } + + if (len > length) { + len = length; + } + + r = randomFile.read(b, off, len); + + if (r > 0) { + offset += r; + length -= r; + } + + return r; + } + + public long skip(long n) throws IOException { + if (length <= 0) { + return 0; + } + + if (n > length) { + n = length; + } + + offset += n; + length -= n; + randomFile.seek(offset); + return n; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/logic/Logic.java b/core/src/main/java/com/sierra/agi/logic/Logic.java new file mode 100644 index 0000000..8c8c8c6 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/logic/Logic.java @@ -0,0 +1,12 @@ +/** + * Logic.java + * Adventure Game Interpreter Logic Package + *

+ * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.logic; + +public interface Logic { +} diff --git a/core/src/main/java/com/sierra/agi/logic/LogicException.java b/core/src/main/java/com/sierra/agi/logic/LogicException.java new file mode 100644 index 0000000..0e6f9e3 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/logic/LogicException.java @@ -0,0 +1,20 @@ +package com.sierra.agi.logic; + +public class LogicException extends Exception { + /** + * Creates new LogicException without detail message. + */ + public LogicException() { + } + + /** + * Constructs a LogicException with the specified detail + * message. + * + * @param msg the detail message. + */ + public LogicException(String msg) { + super(msg); + } + +} diff --git a/core/src/main/java/com/sierra/agi/logic/LogicProvider.java b/core/src/main/java/com/sierra/agi/logic/LogicProvider.java new file mode 100644 index 0000000..8b1c980 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/logic/LogicProvider.java @@ -0,0 +1,16 @@ +/** + * LogicProvider.java + * Adventure Game Interpreter Logic Package + *

+ * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.logic; + +import java.io.IOException; +import java.io.InputStream; + +public interface LogicProvider { + Logic loadLogic(short logicNumber, InputStream inputStream, int size) throws IOException, LogicException; +} diff --git a/core/src/main/java/com/sierra/agi/pic/CorruptedPictureException.java b/core/src/main/java/com/sierra/agi/pic/CorruptedPictureException.java new file mode 100644 index 0000000..f521cf8 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/CorruptedPictureException.java @@ -0,0 +1,21 @@ +/* + * CorruptedPictureException.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public class CorruptedPictureException extends PictureException { + /** + * Creates new CorruptedPictureException without detail message. + */ + public CorruptedPictureException() { + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/pic/Picture.java b/core/src/main/java/com/sierra/agi/pic/Picture.java new file mode 100644 index 0000000..1fbf0ce --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/Picture.java @@ -0,0 +1,43 @@ +/* + * Picture.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.util.Enumeration; +import java.util.Vector; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public class Picture { + protected Vector entries; + + /** + * Creates new Picture + */ + public Picture(Vector entries) { + this.entries = entries; + } + + public PictureContext draw() throws PictureException { + PictureContext pictureContext = new PictureContext(); + + draw(pictureContext); + return pictureContext; + } + + public void draw(PictureContext pictureContext) throws PictureException { + Enumeration en = entries.elements(); + + while (en.hasMoreElements()) { + PictureEntry entry = (PictureEntry) en.nextElement(); + entry.draw(pictureContext); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/pic/PictureContext.java b/core/src/main/java/com/sierra/agi/pic/PictureContext.java new file mode 100644 index 0000000..90e0648 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureContext.java @@ -0,0 +1,310 @@ +/* + * PictureContext.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import com.sierra.agi.awt.EgaUtils; + +import java.awt.Image; +import java.awt.Toolkit; +import java.awt.image.MemoryImageSource; +import java.util.Arrays; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public class PictureContext { + /** + * Picture Dimensions + */ + public int width = 160; + public int height = 168; + + /** + * Picture data. + */ + public int[] picData; + + /** + * Priority data. + */ + public int[] priData; + + /** + * Picture Picture Color. + */ + public int picColor = -1; + + /** + * Picture Priority Color. + */ + public byte priColor = -1; + + /** + * Pen Style + */ + public byte penStyle = 0; + + protected int[] pixel = new int[1]; + + protected int whitePixel; + + /** + * Creates new Picture Context. + */ + public PictureContext() { + picData = new int[width * height]; + priData = new int[width * height]; + + whitePixel = translatePixel((byte) 15); + + Arrays.fill(picData, whitePixel); + Arrays.fill(priData, 4); + } + + /** + * Clips a variable with a maximum. + * + * @param v Variable to be clipped. + * @param max Maximum value that the to be clipped variable can have. + * @return The Variable clipped. + */ + public static int clip(int v, int max) { + if (v > max) + v = max; + + return v; + } + + public int translatePixel(byte b) { + if (b == -1) { + return -1; + } else { + EgaUtils.getNativeColorModel().getDataElements(EgaUtils.getIndexColorModel().getRGB(b), pixel); + return pixel[0]; + } + } + + /** + * Obtain the index in the buffer where (x,y) is located. + * + * @param x X coordinate. + * @param y Y coordinate. + * @return Index in the buffer. + */ + public final int getIndex(int x, int y) { + return (y * width) + x; + } + + /** + * Obtain the color of the pixel asked. + * + * @param x X coordinate. + * @param y Y coordinate. + * @return Color at the specified pixel. + */ + public final int getPixel(int x, int y) { + return picData[(y * width) + x]; + } + + /** + * Obtain the priority of the pixel asked. + * + * @param x X coordinate. + * @param y Y coordinate. + * @return Priority at the specified pixel. + */ + public final int getPriorityPixel(int x, int y) { + return priData[(y * width) + x]; + } + + /** + * Set the (x,y) pixel to the current color and priority. + * + * @param x X coordinate. + * @param y Y coordinate. + * @see #picColor + * @see #priColor + */ + public final void putPixel(int x, int y) { + int i; + + if ((x >= width) || (y >= height)) { + return; + } + + i = (y * width) + x; + + if (picColor >= 0) { + picData[i] = picColor; + } + + if (priColor >= 0) { + priData[i] = priColor; + } + } + + /** + * Draw a line with current color and current priority. + * + * @param x1 Start X Coordinate. + * @param y1 Start Y Coordinate. + * @param x2 End X Coordinate. + * @param y2 End Y Coordinate. + * @see #picColor + * @see #priColor + * @see #putPixel(int, int) + */ + public void drawLine(int x1, int y1, int x2, int y2) { + int x, y; + + /* Clip! */ + x1 = clip(x1, width - 1); + x2 = clip(x2, width - 1); + y1 = clip(y1, height - 1); + y2 = clip(y2, height - 1); + + /* Vertical Line */ + if (x1 == x2) { + if (y1 > y2) { + y = y1; + y1 = y2; + y2 = y; + } + + for (; y1 <= y2; y1++) { + putPixel(x1, y1); + } + } + /* Horizontal Line */ + else if (y1 == y2) { + if (x1 > x2) { + x = x1; + x1 = x2; + x2 = x; + } + + for (; x1 <= x2; x1++) { + putPixel(x1, y1); + } + } else { + int deltaX = x2 - x1; + int deltaY = y2 - y1; + int stepX = 1; + int stepY = 1; + int detDelta; + int errorX; + int errorY; + int count; + + if (deltaY < 0) { + stepY = -1; + deltaY = -deltaY; + } + + if (deltaX < 0) { + stepX = -1; + deltaX = -deltaX; + } + + if (deltaY > deltaX) { + count = deltaY; + detDelta = deltaY; + errorX = deltaY / 2; + errorY = 0; + } else { + count = deltaX; + detDelta = deltaX; + errorX = 0; + errorY = deltaX / 2; + } + + x = x1; + y = y1; + putPixel(x, y); + + do { + errorY = (errorY + deltaY); + if (errorY >= detDelta) { + errorY -= detDelta; + y += stepY; + } + + errorX = (errorX + deltaX); + if (errorX >= detDelta) { + errorX -= detDelta; + x += stepX; + } + + putPixel(x, y); + count--; + } while (count > 0); + + putPixel(x, y); + } + } + + public boolean isFillCorrect(int x, int y) { + if ((picColor < 0) && (priColor < 0)) { + return false; + } + + if ((priColor < 0) && (picColor >= 0) && (picColor != whitePixel)) { + return (getPixel(x, y) == whitePixel); + } + + if ((priColor >= 0) && (picColor < 0) && (priColor != 4)) { + return (getPriorityPixel(x, y) == 4); + } + + return ((picColor >= 0) && (getPixel(x, y) == whitePixel) && (picColor != whitePixel)); + } + + protected Image loadImage(Toolkit toolkit, byte[] data) { + MemoryImageSource mis; + + if (toolkit == null) { + toolkit = Toolkit.getDefaultToolkit(); + } + + mis = new MemoryImageSource(width, height, EgaUtils.getIndexColorModel(), data, 0, width); + return toolkit.createImage(mis); + } + + protected Image loadImage(Toolkit toolkit, int[] data) { + MemoryImageSource mis; + + if (toolkit == null) { + toolkit = Toolkit.getDefaultToolkit(); + } + + mis = new MemoryImageSource(width, height, EgaUtils.getNativeColorModel(), data, 0, width); + return toolkit.createImage(mis); + } + + public Image getPictureImage(Toolkit toolkit) { + return loadImage(toolkit, picData); + } + + public Image getPriorityImage(Toolkit toolkit) { + byte[] asByte = new byte[priData.length]; + for (int i = 0; i < priData.length; i++) { + asByte[i] = (byte) priData[i]; + } + return loadImage(toolkit, asByte); + } + + public int[] getPictureData() { + return picData; + } + + public int[] getPriorityData() { + return priData; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntry.java b/core/src/main/java/com/sierra/agi/pic/PictureEntry.java new file mode 100644 index 0000000..5be569d --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntry.java @@ -0,0 +1,13 @@ +/* + * PictureEntry.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +public abstract class PictureEntry { + public abstract void draw(PictureContext pictureContext); +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryAbsLine.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryAbsLine.java new file mode 100644 index 0000000..e17f280 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryAbsLine.java @@ -0,0 +1,44 @@ +/* + * PictureEntryAbsLine.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.awt.*; +import java.util.Enumeration; + +/** + *

0xF6: Absolute line

+ *

+ * Function: Draws lines between points. The first two arguments are the + * starting coordinates. The remaining arguments are in groups of two which + * give the coordinates of the next location to draw a line to. There can be + * any number of arguments but there should always be an even number. + *

+ * Example: F6 30 50 34 51 38 53 F? + *

+ * This sequence draws a line from (48, 80) to (52, 81), and a line from + * (52, 81) to (56, 83). + *

+ */ +public class PictureEntryAbsLine extends PictureEntryMulti { + public void draw(PictureContext pictureContext) { + Enumeration en = points.elements(); + + Point p1 = (Point) en.nextElement(); + + if (points.size() == 1) { + pictureContext.drawLine(p1.x, p1.y, p1.x, p1.y); + } else { + while (en.hasMoreElements()) { + Point p2 = (Point) en.nextElement(); + pictureContext.drawLine(p1.x, p1.y, p2.x, p2.y); + p1 = p2; + } + } + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePen.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePen.java new file mode 100644 index 0000000..9039f9b --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePen.java @@ -0,0 +1,21 @@ +/* + * PictureEntryChangePen.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +public class PictureEntryChangePen extends PictureEntry { + protected byte penStyle; + + public PictureEntryChangePen(byte penStyle) { + this.penStyle = penStyle; + } + + public void draw(PictureContext pictureContext) { + pictureContext.penStyle = penStyle; + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePicColor.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePicColor.java new file mode 100644 index 0000000..985e9e6 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePicColor.java @@ -0,0 +1,21 @@ +/* + * PictureEntryChangePicColor.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +public class PictureEntryChangePicColor extends PictureEntry { + protected byte picColor; + + public PictureEntryChangePicColor(byte picColor) { + this.picColor = picColor; + } + + public void draw(PictureContext pictureContext) { + pictureContext.picColor = pictureContext.translatePixel(picColor); + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePriColor.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePriColor.java new file mode 100644 index 0000000..08de391 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryChangePriColor.java @@ -0,0 +1,21 @@ +/* + * PictureEntryChangePicColor.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +public class PictureEntryChangePriColor extends PictureEntry { + protected byte priColor; + + public PictureEntryChangePriColor(byte priColor) { + this.priColor = priColor; + } + + public void draw(PictureContext pictureContext) { + pictureContext.priColor = priColor; + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryDrawX.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryDrawX.java new file mode 100644 index 0000000..f592328 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryDrawX.java @@ -0,0 +1,60 @@ +/* + * PictureEntryDrawX.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.awt.*; +import java.util.Enumeration; + +/** + *

0xF5: Draw an X corner

+ * + *

Function: The first two arguments for this action are the coordinates of + * the starting position on the screen in the order x and then y. The remaining + * arguments are in the order x1, y1, x2, y2, ... + *

+ * Note that the x component is the first to be changed and also note that this + * action does not necessarily end on either component, it just ends when the + * next byte of 0xF0 or above is encountered. A line is drawn after each byte + * is processed. + *

+ * Example: F5 16 16 18 12 16 F? + *

+ * (0x16, 0x12)   (0x18, 0x12)
+ * EXX
+ * X            S = Start
+ * X            E = End
+ * X            X = normal piXel
+ * SXX
+ * (0x16, 0x16)   (0x18, 0x16)
+ */ +public class PictureEntryDrawX extends PictureEntryMulti { + public void draw(PictureContext pictureContext) { + Enumeration en = points.elements(); + int x1, y1, x2, y2; + boolean b = true; + Point p; + + p = (Point) en.nextElement(); + x1 = x2 = p.x; + y1 = y2 = p.y; + + while (en.hasMoreElements()) { + if (b) { + x2 = ((Integer) en.nextElement()).intValue(); + } else { + y2 = ((Integer) en.nextElement()).intValue(); + } + + pictureContext.drawLine(x1, y1, x2, y2); + x1 = x2; + y1 = y2; + b = !b; + } + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryDrawY.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryDrawY.java new file mode 100644 index 0000000..3c7243d --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryDrawY.java @@ -0,0 +1,58 @@ +/* + * PictureEntryDrawY.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.awt.*; +import java.util.Enumeration; + +/** + *

0xF4: Draw a Y corner

+ *

+ * Function: The first two arguments for this action are the coordinates of + * the starting position on the screen in the order x and then y. The remaining + * arguments are in the order y1, x1, y2, x2, ... + *

+ * Note that the y component is the first to be changed and also note that this + * action does not necessarily end on either component, it just ends when the + * next byte of 0xF0 or above is encountered. A line is drawn after each byte + * is processed. + *

+ * Example: F4 16 16 18 12 16 F? + *

+ * (0x12, 0x16)     (0x16, 0x16)
+ * E   S                  S = Start
+ * X   X                  E = End
+ * XXXXX                  X = normal piXel
+ * (0x12, 0x18)     (0x16, 0x18)

+ */ +public class PictureEntryDrawY extends PictureEntryMulti { + public void draw(PictureContext pictureContext) { + Enumeration en = points.elements(); + int x1, y1, x2, y2; + boolean b = true; + Point p; + + p = (Point) en.nextElement(); + x1 = x2 = p.x; + y1 = y2 = p.y; + + while (en.hasMoreElements()) { + if (b) { + y2 = ((Integer) en.nextElement()).intValue(); + } else { + x2 = ((Integer) en.nextElement()).intValue(); + } + + pictureContext.drawLine(x1, y1, x2, y2); + x1 = x2; + y1 = y2; + b = !b; + } + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryFill.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryFill.java new file mode 100644 index 0000000..74b0933 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryFill.java @@ -0,0 +1,141 @@ +/* + * PictureEntryFill.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.awt.*; +import java.util.EmptyStackException; +import java.util.Enumeration; + +/** + *

0xF8: Fill

+ *

+ * Function: Flood fill from the locations given. Arguments are given in groups + * of two bytes which give the coordinates of the location to start the fill + * at. If picture drawing is enabled then it flood fills from that location on + * the picture screen to all pixels locations that it can reach which are white + * in colour. The boundary is given by any pixels which are not white. + *

+ * If priority drawing is enabled, and picture drawing is not enabled, then it + * flood fills from that location on the priority screen to all pixels that it + * can reach which are red in colour. The boundary in this case is given by any + * pixels which are not red. + *

+ * If both picture drawing and priority drawing are enabled, then a flood fill + * naturally enough takes place on both screens. In this case there is a + * difference in the way the fill takes place in the priority screen. The + * difference is that it not only looks for its own boundary, but also stops if + * it reaches a boundary that exists in the picture screen but does not + * necessarily exist in the priority screen. + *

+ */ +public class PictureEntryFill extends PictureEntryMulti { + public void draw(PictureContext pictureContext) { + Point current; + Enumeration en = points.elements(); + PointStack stack = new PointStack(200, 200); + int width = pictureContext.width - 1; + int height = pictureContext.height - 1; + + while (en.hasMoreElements()) { + current = (Point) en.nextElement(); + + stack.push(current.x, current.y); + + try { + while (true) { + stack.pop(current); + + if (pictureContext.isFillCorrect(current.x, current.y)) { + pictureContext.putPixel(current.x, current.y); + + if (current.x > 0 && pictureContext.isFillCorrect(current.x - 1, current.y)) { + stack.push(current.x - 1, current.y); + } + + if (current.x < width && pictureContext.isFillCorrect(current.x + 1, current.y)) { + stack.push(current.x + 1, current.y); + } + + if (current.y < height && pictureContext.isFillCorrect(current.x, current.y + 1)) { + stack.push(current.x, current.y + 1); + } + + if (current.y > 0 && pictureContext.isFillCorrect(current.x, current.y - 1)) { + stack.push(current.x, current.y - 1); + } + } + } + } catch (EmptyStackException esex) { + } + } + } + + public static class PointStack { + protected int increment; + protected int elementCount; + protected short[] x; + protected short[] y; + + /** + * Creates new Point Stack + */ + public PointStack() { + increment = 15; + } + + public PointStack(int initialSize, int increment) { + this.increment = increment; + ensureCapacity(initialSize); + } + + public void pop(Point pt) { + if (elementCount == 0) { + throw new EmptyStackException(); + } + + elementCount--; + pt.x = x[elementCount]; + pt.y = y[elementCount]; + } + + public void push(int x, int y) { + ensureCapacity(elementCount + 1); + + this.x[elementCount] = (short) x; + this.y[elementCount] = (short) y; + elementCount++; + } + + public void clear() { + elementCount = 0; + } + + public void ensureCapacity(int minCapacity) { + if (x == null) { + x = new short[minCapacity + increment]; + y = new short[minCapacity + increment]; + } else if (x.length < minCapacity) { + short[] nx = new short[minCapacity + increment]; + short[] ny = new short[minCapacity + increment]; + int i, l = elementCount; + + for (i = 0; i < l; i++) { + nx[i] = x[i]; + } + + for (i = 0; i < l; i++) { + ny[i] = y[i]; + } + + x = nx; + y = ny; + } + } + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryMulti.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryMulti.java new file mode 100644 index 0000000..40122de --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryMulti.java @@ -0,0 +1,28 @@ +/* + * PictureEntryMulti.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.awt.*; +import java.util.Vector; + +public abstract class PictureEntryMulti extends PictureEntry { + protected Vector points = new Vector(); + + public void add(int x, int y) { + points.add(new Point(x, y)); + } + + public void add(int c) { + points.add(Integer.valueOf(c)); + } + + public void add(int[] c) { + points.add(c); + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryPlot.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryPlot.java new file mode 100644 index 0000000..c4c46ad --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryPlot.java @@ -0,0 +1,164 @@ +/* + * PictureEntryPlot.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.awt.*; +import java.util.Enumeration; + +public class PictureEntryPlot extends PictureEntryMulti { + /** + * Circle Bitmaps + */ + protected static final short[][] circles = new short[][] + { + {0x80}, + {0xfc}, + {0x5f, 0xf4}, + {0x66, 0xff, 0xf6, 0x60}, + {0x23, 0xbf, 0xff, 0xff, 0xee, 0x20}, + {0x31, 0xe7, 0x9e, 0xff, 0xff, 0xde, 0x79, 0xe3, 0x00}, + {0x38, 0xf9, 0xf3, 0xef, 0xff, 0xff, 0xff, 0xfe, 0xf9, 0xf3, 0xe3, 0x80}, + {0x18, 0x3c, 0x7e, 0x7e, 0x7e, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7e, 0x7e, 0x7e, 0x3c, 0x18} + }; + + /** + * Splatter Brush Bitmaps + */ + protected static final short[] splatterMap = new short[] + { + 0x20, 0x94, 0x02, 0x24, 0x90, 0x82, 0xa4, 0xa2, + 0x82, 0x09, 0x0a, 0x22, 0x12, 0x10, 0x42, 0x14, + 0x91, 0x4a, 0x91, 0x11, 0x08, 0x12, 0x25, 0x10, + 0x22, 0xa8, 0x14, 0x24, 0x00, 0x50, 0x24, 0x04 + }; + + /** + * Starting Bit Position + */ + protected static final short[] splatterStart = new short[] + { + 0x00, 0x18, 0x30, 0xc4, 0xdc, 0x65, 0xeb, 0x48, + 0x60, 0xbd, 0x89, 0x05, 0x0a, 0xf4, 0x7d, 0x7d, + 0x85, 0xb0, 0x8e, 0x95, 0x1f, 0x22, 0x0d, 0xdf, + 0x2a, 0x78, 0xd5, 0x73, 0x1c, 0xb4, 0x40, 0xa1, + 0xb9, 0x3c, 0xca, 0x58, 0x92, 0x34, 0xcc, 0xce, + 0xd7, 0x42, 0x90, 0x0f, 0x8b, 0x7f, 0x32, 0xed, + 0x5c, 0x9d, 0xc8, 0x99, 0xad, 0x4e, 0x56, 0xa6, + 0xf7, 0x68, 0xb7, 0x25, 0x82, 0x37, 0x3a, 0x51, + 0x69, 0x26, 0x38, 0x52, 0x9e, 0x9a, 0x4f, 0xa7, + 0x43, 0x10, 0x80, 0xee, 0x3d, 0x59, 0x35, 0xcf, + 0x79, 0x74, 0xb5, 0xa2, 0xb1, 0x96, 0x23, 0xe0, + 0xbe, 0x05, 0xf5, 0x6e, 0x19, 0xc5, 0x66, 0x49, + 0xf0, 0xd1, 0x54, 0xa9, 0x70, 0x4b, 0xa4, 0xe2, + 0xe6, 0xe5, 0xab, 0xe4, 0xd2, 0xaa, 0x4c, 0xe3, + 0x06, 0x6f, 0xc6, 0x4a, 0xa4, 0x75, 0x97, 0xe1 + }; + + public void draw(PictureContext pictureContext) { + if ((pictureContext.penStyle & 0x20) == 0x20) { + drawPlot(pictureContext); + } else { + drawPoints(pictureContext); + } + } + + public void drawPlot(PictureContext pictureContext) { + Enumeration en = points.elements(); + int circlePos = 0; + int bitPos; + int x, y, x1, y1, penSize, penSizeTrue; + boolean circle; + int[] p; + + circle = !((pictureContext.penStyle & 0x10) == 0x10); + penSize = (pictureContext.penStyle & 0x07); + penSizeTrue = penSize; + + while (en.hasMoreElements()) { + p = (int[]) en.nextElement(); + circlePos = 0; + bitPos = splatterStart[p[0]]; + x = p[1]; + y = p[2]; + + if (x < penSize) { + x = penSize - 1; + } + + if (y < penSize) { + y = penSize; + } + + for (y1 = y - penSize; y1 <= y + penSize; y1++) { + for (x1 = x - (penSize + 1) / 2; x1 <= x + penSize / 2; x1++) { + if (circle) { + if (!(((circles[penSizeTrue][circlePos >> 0x3] >> (0x7 - (circlePos & 0x7))) & 0x1) == 0x1)) { + circlePos++; + continue; + } + + circlePos++; + } + + if (((splatterMap[bitPos >> 3] >> (7 - (bitPos & 7))) & 1) == 1) { + pictureContext.putPixel(x1, y1); + } + + bitPos++; + + if (bitPos == 0xff) { + bitPos = 0; + } + } + } + } + } + + public void drawPoints(PictureContext pictureContext) { + Enumeration en = points.elements(); + int circlePos; + int x, y, x1, y1, penSize, penSizeTrue; + boolean circle; + Point p; + + circle = !((pictureContext.penStyle & 0x10) == 0x10); + penSize = (pictureContext.penStyle & 0x07); + penSizeTrue = penSize; + + while (en.hasMoreElements()) { + p = (Point) en.nextElement(); + x = p.x; + y = p.y; + circlePos = 0; + + if (x < penSize) { + x = penSize - 1; + } + + if (y < penSize) { + y = penSize; + } + + for (y1 = y - penSize; y1 <= y + penSize; y1++) { + for (x1 = x - (penSize + 1) / 2; x1 <= x + penSize / 2; x1++) { + if (circle) { + if (!(((circles[penSizeTrue][circlePos >> 0x3] >> (0x7 - (circlePos & 0x7))) & 0x1) == 0x1)) { + circlePos++; + continue; + } + + circlePos++; + } + + pictureContext.putPixel(x1, y1); + } + } + } + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureEntryRelLine.java b/core/src/main/java/com/sierra/agi/pic/PictureEntryRelLine.java new file mode 100644 index 0000000..49df638 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureEntryRelLine.java @@ -0,0 +1,66 @@ +/* + * PictureEntryRelLine.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.awt.*; +import java.util.Enumeration; + +/** + *

0xF7: Relative line

+ *

+ * Function: Draw short relative lines. By relative we mean that the data gives + * displacements which are relative from the current location. The first + * argument gives the standard starting coordinates. All the arguments which + * follow these first two are of the following format: + *

+ * +---+-----------+---+-----------+
+ * | S |   Xdisp   | S |   Ydisp   |
+ * +---+---+---+---+---+---+---+---+
+ * | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
+ * +---+---+---+---+---+---+---+---+
+ *

+ * This gives a displacement range of between -7 and +7 for both the X and the Y + * direction. + *

+ * Example: F7 10 10 22 40 06 CC F? + *

+ * S
+ * +              S = Start
+ * X+++X         X = End of each line
+ * +         + = pixels in each line
+ * E   +         E = End
+ * +  +
+ * + +         Remember that CC = (x-4, y-4).
+ * ++
+ * X
+ */ + +public class PictureEntryRelLine extends PictureEntryMulti { + public void draw(PictureContext pictureContext) { + Enumeration en = points.elements(); + Point p; + int x1, y1, x2, y2; + + p = (Point) en.nextElement(); + x1 = x2 = p.x; + y1 = y2 = p.y; + + pictureContext.putPixel(x1, y1); + + while (en.hasMoreElements()) { + p = (Point) en.nextElement(); + x2 += p.x; + y2 += p.y; + + pictureContext.drawLine(x1, y1, x2, y2); + x1 = x2; + y1 = y2; + } + } +} diff --git a/core/src/main/java/com/sierra/agi/pic/PictureException.java b/core/src/main/java/com/sierra/agi/pic/PictureException.java new file mode 100644 index 0000000..adcdee4 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureException.java @@ -0,0 +1,31 @@ +/* + * PictureException.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public class PictureException extends Exception { + /** + * Creates new PictureException without detail message. + */ + public PictureException() { + } + + /** + * Constructs an PictureException with the specified detail + * message. + * + * @param msg the detail message. + */ + public PictureException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/pic/PictureProvider.java b/core/src/main/java/com/sierra/agi/pic/PictureProvider.java new file mode 100644 index 0000000..22a742c --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/PictureProvider.java @@ -0,0 +1,16 @@ +/* + * PictureProvider.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.io.IOException; +import java.io.InputStream; + +public interface PictureProvider { + Picture loadPicture(InputStream inputStream) throws IOException, PictureException; +} diff --git a/core/src/main/java/com/sierra/agi/pic/StandardPictureProvider.java b/core/src/main/java/com/sierra/agi/pic/StandardPictureProvider.java new file mode 100644 index 0000000..0e555fc --- /dev/null +++ b/core/src/main/java/com/sierra/agi/pic/StandardPictureProvider.java @@ -0,0 +1,414 @@ +/* + * StandardPictureProvider.java + * Adventure Game Interpreter Picture Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.pic; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Vector; + +public class StandardPictureProvider implements PictureProvider { + protected static final short CMD_START = (short) 0xF0; + + protected static final short CMD_CHANGEPICCOLOR = (short) 0xF0; + protected static final short CMD_DISABLEPICDRAW = (short) 0xF1; + protected static final short CMD_CHANGEPRICOLOR = (short) 0xF2; + protected static final short CMD_DISABLEPRIDRAW = (short) 0xF3; + protected static final short CMD_DRAWYCORNER = (short) 0xF4; + protected static final short CMD_DRAWXCORNER = (short) 0xF5; + protected static final short CMD_DRAWABSLINE = (short) 0xF6; + protected static final short CMD_DRAWRELLINE = (short) 0xF7; + protected static final short CMD_FILL = (short) 0xF8; + protected static final short CMD_CHANGEPEN = (short) 0xF9; + protected static final short CMD_PLOT = (short) 0xFA; + protected static final short CMD_EOP = (short) 0xFF; + + public Picture loadPicture(InputStream in) throws IOException, PictureException { + int command, c, x, y; + Vector entries = new Vector(); + int lastPen = 0; + PictureEntryMulti entry; + + try { + command = in.read(); + + while (true) { + if (command < 0) { + break; + } + + switch (command) { + case CMD_CHANGEPICCOLOR: + entries.add(new PictureEntryChangePicColor((byte) in.read())); + command = in.read(); + break; + + case CMD_CHANGEPRICOLOR: + entries.add(new PictureEntryChangePriColor((byte) in.read())); + command = in.read(); + break; + + case CMD_DISABLEPICDRAW: + entries.add(new PictureEntryChangePicColor((byte) -1)); + command = in.read(); + break; + + case CMD_DISABLEPRIDRAW: + entries.add(new PictureEntryChangePriColor((byte) -1)); + command = in.read(); + break; + + case CMD_DRAWXCORNER: + case CMD_DRAWYCORNER: + if (command == CMD_DRAWXCORNER) { + entry = new PictureEntryDrawX(); + } else { + entry = new PictureEntryDrawY(); + } + + entry.add(in.read(), in.read()); + + while (true) { + command = in.read(); + + if ((command >= CMD_START) || (command < 0)) { + break; + } + + entry.add(command); + } + + entries.add(entry); + break; + + case CMD_DRAWABSLINE: + entry = new PictureEntryAbsLine(); + entry.add(in.read(), in.read()); + + while (true) { + command = in.read(); + + if ((command >= CMD_START) || (command < 0)) { + break; + } + + entry.add(command, in.read()); + } + + entries.add(entry); + break; + + case CMD_DRAWRELLINE: + entry = new PictureEntryRelLine(); + entry.add(in.read(), in.read()); + + while (true) { + command = in.read(); + + if ((command >= CMD_START) || (command < 0)) { + break; + } + + x = (command & 0x70) >> 4; + y = (command & 0x07); + + if ((command & 0x80) == 0x80) { + x = -x; + } + + if ((command & 0x08) == 0x08) { + y = -y; + } + + entry.add(x, y); + } + + entries.add(entry); + break; + + case CMD_FILL: + entry = new PictureEntryFill(); + + while (true) { + command = in.read(); + + if ((command >= CMD_START) || (command < 0)) { + break; + } + + c = in.read(); + entry.add(command, c); + } + + entries.add(entry); + break; + + case CMD_CHANGEPEN: + lastPen = in.read(); + entries.add(new PictureEntryChangePen((byte) lastPen)); + command = in.read(); + break; + + case CMD_PLOT: + entry = new PictureEntryPlot(); + + while (true) { + command = in.read(); + + if ((command < 0) || (command >= CMD_START)) { + break; + } + + if ((lastPen & 0x20) == 0x20) { + command = (command >> 1) & 0x7f; + x = in.read(); + y = in.read(); + entry.add(new int[]{command, x, y}); + } else { + x = command; + y = in.read(); + entry.add(x, y); + } + } + + entries.add(entry); + break; + + case CMD_EOP: + command = -1; + break; + + default: + throw new CorruptedPictureException(); + } + } + } catch (EOFException eex) { + } + + in.close(); + return new Picture(entries); + } + + /* + protected boolean next() throws PictureException + { + if (in == null) + { + return false; + } + + try + { + if (nextCommand < 0) + { + nextCommand = in.read(); + + if (nextCommand < 0) + { + if (provider != null) + { + in.close(); + in = null; + } + + endReached = true; + return false; + } + } + + switch (nextCommand) + { + case CMD_CHANGEPICCOLOR: + picContext.picColor = (byte)in.read(); + nextCommand = -1; + break; + + case CMD_CHANGEPRICOLOR: + picContext.priColor = (byte)in.read(); + nextCommand = -1; + break; + + case CMD_DISABLEPICDRAW: + picContext.picColor = (byte)-1; + nextCommand = -1; + break; + + case CMD_DISABLEPRIDRAW: + picContext.priColor = (byte)-1; + nextCommand = -1; + break; + + case CMD_DRAWXCORNER: + drawXCorner(); + break; + + case CMD_DRAWYCORNER: + drawYCorner(); + break; + + case CMD_DRAWABSLINE: + drawAbsoluteLine(); + break; + + case CMD_DRAWRELLINE: + drawRelativeLine(); + break; + + case CMD_FILL: + drawFill(); + break; + + case CMD_CHANGEPEN: + picContext.penStyle = (byte)in.read(); + nextCommand = -1; + break; + + case CMD_PLOT: + drawPlot(); + break; + + case CMD_EOP: + in.close(); + in = null; + break; + + default: + throw new CorruptedPictureException(); + } + } + catch (IOException ioex) + { + in = null; + } + + return true; + }*/ + + /* + protected void drawPlot() throws IOException + { + int c, x, y; + + while (true) + { + c = in.read(); + + if ((c < 0) || (c >= CMD_START)) + { + nextCommand = c; + break; + } + + if ((picContext.penStyle & 0x20) == 0x20) + { + c = (c >> 1) & 0x7f; + x = in.read(); + y = in.read(); + drawPlot(c, x, y); + } + else + { + x = c; + y = in.read(); + drawPlot(x, y); + } + } + } + + protected void drawPlot(int patternNumber, int x, int y) + { + int circlePos = 0; + int bitPos = splatterStart[patternNumber]; + int x1, y1, penSize, penSizeTrue; + boolean circle; + + circle = !((picContext.penStyle & 0x10) == 0x10); + penSize = (picContext.penStyle & 0x07); + penSizeTrue = penSize; + + if (x < penSize) + { + x = penSize - 1; + } + + if (y < penSize) + { + y = penSize; + } + + for (y1 = y - penSize; y1 <= y + penSize; y1++) + { + for (x1 = x - (penSize + 1) / 2; x1 <= x + penSize / 2; x1++) + { + if (circle) + { + if (!(((circles[penSizeTrue][circlePos >> 0x3] >> (0x7 - (circlePos & 0x7))) & 0x1) == 0x1)) + { + circlePos++; + continue; + } + + circlePos++; + } + + if (((splatterMap[bitPos >> 3] >> (7 - (bitPos & 7))) & 1) == 1) + { + picContext.putPixel(x1, y1); + } + + bitPos++; + + if (bitPos == 0xff) + { + bitPos = 0; + } + } + } + } + + protected void drawPlot(int x, int y) + { + int circlePos = 0; + int x1, y1, penSize, penSizeTrue; + boolean circle; + + circle = !((picContext.penStyle & 0x10) == 0x10); + penSize = (picContext.penStyle & 0x07); + penSizeTrue = penSize; + + if (x < penSize) + { + x = penSize - 1; + } + + if (y < penSize) + { + y = penSize; + } + + for (y1 = y - penSize; y1 <= y + penSize; y1++) + { + for (x1 = x - (penSize + 1) / 2; x1 <= x + penSize / 2; x1++) + { + if (circle) + { + if (!(((circles[penSizeTrue][circlePos >> 0x3] >> (0x7 - (circlePos & 0x7))) & 0x1) == 0x1)) + { + circlePos++; + continue; + } + + circlePos++; + } + + picContext.putPixel(x1, y1); + } + } + }*/ +} diff --git a/core/src/main/java/com/sierra/agi/res/CorruptedResourceException.java b/core/src/main/java/com/sierra/agi/res/CorruptedResourceException.java new file mode 100644 index 0000000..401f97d --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/CorruptedResourceException.java @@ -0,0 +1,33 @@ +/** + * CorruptedResourceException.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +/** + * The resource is currupted. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public final class CorruptedResourceException extends ResourceException { + /** + * Creates new CorruptedResourceException without detail message. + */ + public CorruptedResourceException() { + super(); + } + + /** + * Constructs an CorruptedResourceException with the specified detail message. + * + * @param msg Detail message. + */ + public CorruptedResourceException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/NoDirectoryAvailableException.java b/core/src/main/java/com/sierra/agi/res/NoDirectoryAvailableException.java new file mode 100644 index 0000000..89b7c9e --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/NoDirectoryAvailableException.java @@ -0,0 +1,34 @@ +/** + * NoDirectoryAvailableException.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +/** + * There is no directory available. Throwed when a ResourceProvider is created + * with a folder that doesn't contain any resource directory. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public final class NoDirectoryAvailableException extends ResourceException { + /** + * Creates new NoDirectoryAvailableException without detail message. + */ + public NoDirectoryAvailableException() { + super(); + } + + /** + * Constructs an NoDirectoryAvailableException with the specified detail message. + * + * @param msg Detail message. + */ + public NoDirectoryAvailableException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/NoVolumeAvailableException.java b/core/src/main/java/com/sierra/agi/res/NoVolumeAvailableException.java new file mode 100644 index 0000000..3803ce9 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/NoVolumeAvailableException.java @@ -0,0 +1,34 @@ +/** + * NoVolumeAvailableException.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +/** + * There is no volume available. Throwed when a ResourceProvider is created + * with a folder that doesn't contain any resource directory. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public final class NoVolumeAvailableException extends ResourceException { + /** + * Creates new NoVolumeAvailableException without detail message. + */ + public NoVolumeAvailableException() { + super(); + } + + /** + * Constructs an NoVolumeAvailableException with the specified detail message. + * + * @param msg Detail message. + */ + public NoVolumeAvailableException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/ResourceCache.java b/core/src/main/java/com/sierra/agi/res/ResourceCache.java new file mode 100644 index 0000000..e1fff83 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceCache.java @@ -0,0 +1,370 @@ +/** + * ResourceCache.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +import com.sierra.agi.inv.InventoryObjects; +import com.sierra.agi.inv.InventoryProvider; +import com.sierra.agi.logic.Logic; +import com.sierra.agi.logic.LogicException; +import com.sierra.agi.logic.LogicProvider; +import com.sierra.agi.pic.Picture; +import com.sierra.agi.pic.PictureException; +import com.sierra.agi.pic.PictureProvider; +import com.sierra.agi.sound.Sound; +import com.sierra.agi.sound.SoundProvider; +import com.sierra.agi.view.View; +import com.sierra.agi.view.ViewException; +import com.sierra.agi.view.ViewProvider; +import com.sierra.agi.word.Words; +import com.sierra.agi.word.WordsProvider; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.lang.reflect.Constructor; + +public class ResourceCache { + protected static Class[] providerParameters = {ResourceConfiguration.class}; + protected LogicProvider logProvider; + protected InventoryProvider invProvider; + protected PictureProvider picProvider; + protected ResourceProvider resProvider; + protected SoundProvider sndProvider; + protected ViewProvider viwProvider; + protected WordsProvider wrdProvider; + protected Object[] logics; + protected int[] logicsc; + protected Object[] pictures; + protected int[] picturesc; + protected Object[] sounds; + protected int[] soundsc; + protected Object[] views; + protected int[] viewsc; + protected Words words; + protected InventoryObjects objects; + + protected ResourceCache() { + } + + public ResourceCache(ResourceProvider resProvider) { + this.resProvider = resProvider; + } + + public synchronized ResourceProvider getResourceProvider() { + return resProvider; + } + + protected Object getProvider(String clazzName) { + try { + try { + return Class.forName(clazzName).newInstance(); + } catch (InstantiationException e) { + Class clazz = Class.forName(clazzName); + Constructor cons = clazz.getConstructor(providerParameters); + Object[] o = new Object[1]; + + o[0] = resProvider.getConfiguration(); + + return cons.newInstance(o); + } + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e.getClass().getName() + ": " + clazzName); + } + } + + public synchronized SoundProvider getSoundProvider() { + if (sndProvider == null) { + sndProvider = (SoundProvider) getProvider(System.getProperty("com.sierra.agi.sound.SoundProvider", "com.sierra.agi.sound.StandardSoundProvider")); + } + + return sndProvider; + } + + public synchronized void setSoundProvider(SoundProvider sndProvider) { + this.sndProvider = sndProvider; + } + + public synchronized InventoryProvider getInventoryProvider() { + if (invProvider == null) { + invProvider = (InventoryProvider) getProvider(System.getProperty("com.sierra.agi.inv.LogicProvider", "com.sierra.agi.inv.InventoryObjects")); + } + + return invProvider; + } + + public synchronized void setInventoryProvider(InventoryProvider invProvider) { + this.invProvider = invProvider; + } + + public synchronized LogicProvider getLogicProvider() { + if (logProvider == null) { + logProvider = (LogicProvider) getProvider(System.getProperty("com.sierra.agi.logic.LogicProvider", "com.sierra.agi.logic.StandardLogicProvider")); + } + + return logProvider; + } + + public synchronized void setLogicProvider(LogicProvider logProvider) { + this.logProvider = logProvider; + } + + public synchronized ViewProvider getViewProvider() { + if (viwProvider == null) { + viwProvider = (ViewProvider) getProvider(System.getProperty("com.sierra.agi.view.ViewProvider", "com.sierra.agi.view.StandardViewProvider")); + } + + return viwProvider; + } + + public synchronized void setViewProvider(ViewProvider viwProvider) { + this.viwProvider = viwProvider; + } + + public synchronized WordsProvider getWordsProvider() { + if (wrdProvider == null) { + wrdProvider = (WordsProvider) getProvider(System.getProperty("com.sierra.agi.word.WordsProvider", "com.sierra.agi.word.Words")); + } + + return wrdProvider; + } + + public synchronized void setWordsProvider(WordsProvider wrdProvider) { + this.wrdProvider = wrdProvider; + } + + public synchronized PictureProvider getPictureProvider() { + if (picProvider == null) { + picProvider = (PictureProvider) getProvider(System.getProperty("com.sierra.agi.pic.PictureProvider", "com.sierra.agi.pic.StandardPictureProvider")); + } + + return picProvider; + } + + public synchronized void setPictureProvider(PictureProvider picProvider) { + this.picProvider = picProvider; + } + + protected Object obtainResource(Object[] objects, int[] objectsc, short resNumber, boolean inc) { + Object obj = objects[resNumber]; + + if (obj != null) { + if (obj instanceof Reference) { + obj = ((Reference) obj).get(); + + if (inc) { + objects[resNumber] = obj; + } + + if (obj == null) { + return null; + } + } + + if (inc) { + objectsc[resNumber]++; + } + + return obj; + } + + return null; + } + + protected void flushResource(Object[] objects, int[] objectsc, short resNumber) { + if (objectsc[resNumber] > 0) { + objectsc[resNumber]--; + } + + if (objects[resNumber] == null) { + return; + } + + if (objectsc[resNumber] <= 0) { + if (!(objects[resNumber] instanceof Reference)) { + objects[resNumber] = generateReference(objects[resNumber]); + } + } + } + + protected synchronized Sound obtainSound(short resNumber, boolean inc) throws IOException, ResourceException { + Object o; + + if (sounds == null) { + sounds = new Object[256]; + soundsc = new int[256]; + } + + o = obtainResource(sounds, soundsc, resNumber, inc); + + if (o == null) { + o = getSoundProvider().loadSound(resProvider.open(ResourceProvider.TYPE_SOUND, resNumber)); + + if (inc) { + sounds[resNumber] = o; + soundsc[resNumber] = 1; + } else { + sounds[resNumber] = generateReference(o); + } + } + + return (Sound) o; + } + + public void loadSound(short resNumber) throws IOException, ResourceException { + obtainSound(resNumber, true); + } + + public Sound getSound(short resNumber) throws IOException, ResourceException { + return obtainSound(resNumber, false); + } + + public synchronized void unloadSound(short resNumber) { + flushResource(sounds, soundsc, resNumber); + } + + protected synchronized Logic obtainLogic(short resNumber, boolean inc) throws IOException, ResourceException, LogicException { + Object o; + + if (logics == null) { + logics = new Object[256]; + logicsc = new int[256]; + } + + o = obtainResource(logics, logicsc, resNumber, inc); + + if (o == null) { + o = getLogicProvider().loadLogic(resNumber, resProvider.open(ResourceProvider.TYPE_LOGIC, resNumber), resProvider.getSize(ResourceProvider.TYPE_LOGIC, resNumber)); + + if (inc) { + logics[resNumber] = o; + logicsc[resNumber] = 1; + } else { + logics[resNumber] = generateReference(o); + } + } + + return (Logic) o; + } + + public void loadLogic(short resNumber) throws IOException, ResourceException, LogicException { + obtainLogic(resNumber, true); + } + + public Logic getLogic(short resNumber) throws IOException, ResourceException, LogicException { + return obtainLogic(resNumber, false); + } + + public synchronized void unloadLogic(short resNumber) { + flushResource(logics, logicsc, resNumber); + } + + protected synchronized Picture obtainPicture(short resNumber, boolean inc) throws IOException, ResourceException, PictureException { + Object o; + + if (pictures == null) { + pictures = new Object[256]; + picturesc = new int[256]; + } + + o = obtainResource(pictures, picturesc, resNumber, inc); + + if (o == null) { + o = getPictureProvider().loadPicture(resProvider.open(ResourceProvider.TYPE_PICTURE, resNumber)); + + if (inc) { + pictures[resNumber] = o; + picturesc[resNumber] = 1; + } else { + pictures[resNumber] = generateReference(o); + } + } + + return (Picture) o; + } + + public void loadPicture(short resNumber) throws IOException, ResourceException, PictureException { + obtainPicture(resNumber, true); + } + + public Picture getPicture(short resNumber) throws IOException, ResourceException, PictureException { + return obtainPicture(resNumber, false); + } + + public synchronized void unloadPicture(short resNumber) { + flushResource(pictures, picturesc, resNumber); + } + + protected synchronized View obtainView(short resNumber, boolean inc) throws IOException, ResourceException, ViewException { + if (views == null) { + views = new Object[256]; + viewsc = new int[256]; + } + + Object o = obtainResource(views, viewsc, resNumber, inc); + + if (o == null) { + o = getViewProvider().loadView(resProvider.open(ResourceProvider.TYPE_VIEW, resNumber), resProvider.getSize(ResourceProvider.TYPE_VIEW, resNumber)); + + if (inc) { + views[resNumber] = o; + viewsc[resNumber] = 1; + } else { + views[resNumber] = generateReference(o); + } + } + + return (View) o; + } + + public void loadView(short resNumber) throws IOException, ResourceException, ViewException { + obtainView(resNumber, true); + } + + public View getView(short resNumber) throws IOException, ResourceException, ViewException { + return obtainView(resNumber, false); + } + + public synchronized void unloadView(short resNumber) { + flushResource(views, viewsc, resNumber); + } + + public synchronized Words getWords() throws IOException, ResourceException { + if (words == null) { + words = getWordsProvider().loadWords(resProvider.open(ResourceProvider.TYPE_WORD, (short) 0)); + } + + return words; + } + + public synchronized InventoryObjects getObjects() throws IOException, ResourceException { + if (objects == null) { + objects = getInventoryProvider().loadInventory(resProvider.open(ResourceProvider.TYPE_OBJECT, (short) 0)); + } + + return objects; + } + + protected Reference generateReference(Object o) { + return new WeakReference(o); + } + + public File getPath() { + return resProvider.getPath(); + } + + public String getVersion() { + return this.resProvider.getVersion(); + } + + public String getV3GameSig() { + return this.resProvider.getV3GameSig(); + } +} diff --git a/core/src/main/java/com/sierra/agi/res/ResourceCacheFile.java b/core/src/main/java/com/sierra/agi/res/ResourceCacheFile.java new file mode 100644 index 0000000..d566c48 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceCacheFile.java @@ -0,0 +1,44 @@ +/** + * ResourceCacheFile.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +public class ResourceCacheFile extends ResourceCache { + public ResourceCacheFile(File file) throws IOException, ResourceException { + if (!file.exists()) { + throw new FileNotFoundException(); + } + + if (file.isDirectory()) { + loadFS(file); + } + + if (resProvider == null) { + String p = file.getPath(); + + if (p.endsWith(".zip")) { + //resProvider = new ResourceProviderZip(file); + } else { + loadFS(file); + } + } + } + + private void loadFS(File file) throws IOException, ResourceException { + try { + resProvider = new com.sierra.agi.res.v2.ResourceProviderV2(file); + } catch (ResourceException e) { + System.out.println("Found AGI Version 3 Game."); + resProvider = new com.sierra.agi.res.v3.ResourceProviderV3(file); + } + } +} diff --git a/core/src/main/java/com/sierra/agi/res/ResourceCacheFileDebug.java b/core/src/main/java/com/sierra/agi/res/ResourceCacheFileDebug.java new file mode 100644 index 0000000..bb4c890 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceCacheFileDebug.java @@ -0,0 +1,18 @@ +/* + * ResourceCacheFileDebug.java + * Adventure Game Interpreter Logic Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +import java.io.File; +import java.io.IOException; + +public class ResourceCacheFileDebug extends ResourceCacheFile { + public ResourceCacheFileDebug(File file) throws IOException, ResourceException { + super(file); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/ResourceConfiguration.java b/core/src/main/java/com/sierra/agi/res/ResourceConfiguration.java new file mode 100644 index 0000000..743e3af --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceConfiguration.java @@ -0,0 +1,16 @@ +/** + * ResourceConfiguration.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +public class ResourceConfiguration { + public short engineEmulation; + public boolean amiga; + public boolean agds; + public String name; +} diff --git a/core/src/main/java/com/sierra/agi/res/ResourceException.java b/core/src/main/java/com/sierra/agi/res/ResourceException.java new file mode 100644 index 0000000..3f103f6 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceException.java @@ -0,0 +1,33 @@ +/** + * ResourceException.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +/** + * Base class for Resource Exceptions. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class ResourceException extends Exception { + /** + * Creates new ResourceException without detail message. + */ + public ResourceException() { + super(); + } + + /** + * Constructs an ResourceException with the specified detail message. + * + * @param msg Detail message. + */ + public ResourceException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/ResourceNotExistingException.java b/core/src/main/java/com/sierra/agi/res/ResourceNotExistingException.java new file mode 100644 index 0000000..6abeafc --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceNotExistingException.java @@ -0,0 +1,33 @@ +/** + * ResourceNotExistingException.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +/** + * The resource doesn't exists. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public final class ResourceNotExistingException extends ResourceException { + /** + * Creates new ResourceNotExistingException without detail message. + */ + public ResourceNotExistingException() { + super(); + } + + /** + * Constructs an ResourceNotExistingException with the specified detail message. + * + * @param msg Detail message. + */ + public ResourceNotExistingException(String msg) { + super(msg); + } +} diff --git a/core/src/main/java/com/sierra/agi/res/ResourceProvider.java b/core/src/main/java/com/sierra/agi/res/ResourceProvider.java new file mode 100644 index 0000000..108a32d --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceProvider.java @@ -0,0 +1,114 @@ +/** + * ResourceProvider.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * The ResourceProvider interface is a standard + * way for loading resources dynamicly. Gives the + * interresing possibility of being able to read + * them from every kind of data container. + * + * @author Dr. Z + * @version 0.00.00.02 + */ +public interface ResourceProvider { + /** Logic Resource Type. */ + byte TYPE_LOGIC = 0; + + /** Picture Resource Type. */ + byte TYPE_PICTURE = 1; + + /** Sound Resource Type. */ + byte TYPE_SOUND = 2; + + /** View Resource Type. */ + byte TYPE_VIEW = 3; + + /** Compiled Logic Resource Type. (Reserverd for future uses) */ + byte TYPE_LOGIC_COMPILED = 4; + + /** Object File. */ + byte TYPE_OBJECT = 10; + + /** Word Tokenizer File. */ + byte TYPE_WORD = 11; + + /** Fast Provider Type. (ie. File system) */ + byte PROVIDER_TYPE_FAST = 0; + + /** Slow Provider Type. (ie. URL) */ + byte PROVIDER_TYPE_SLOW = 1; + + /** + * Calculate the CRC of the resources. + * + * @return CRC of the resources. + */ + long getCRC(); + + /** + * Retreive the count of resources of the specified type. + * + * @param resType Resource type + * @return Resource count. + */ + int count(byte resType) throws ResourceException; + + /** + * Enumerate the resource numbers of the specified type. + * + * @param resType Resource type + * @return Returns an array containing the resource numbers. + */ + short[] enumerate(byte resType) throws ResourceException; + + /** + * Retreive the size in bytes of the specified resource. + * + * @param resType Resource type + * @param resNumber Resource number + * @return Returns the size in bytes of the specified resource. + */ + int getSize(byte resType, short resNumber) throws ResourceException, IOException; + + /** + * Open the specified resource and return a pointer + * to the resource. The InputStream is decrypted/decompressed, + * if neccessary, by this function. (So you don't have to care + * about them.) + * + * @param resType Resource type + * @param resNumber Resource number + * @return InputStream linked to the specified resource. + */ + InputStream open(byte resType, short resNumber) throws ResourceException, IOException; + + /** + * Return the provider type. Used has a optimization hint by + * the resource cache. (For example, PROVIDER_TYPE_SLOW whould + * mean to never ask twice for the same resource because transfert + * rate may be slow.) + */ + byte getProviderType(); + + /** + * Return the resource configuration. + */ + ResourceConfiguration getConfiguration(); + + File getPath(); + + String getVersion(); + + String getV3GameSig(); +} diff --git a/core/src/main/java/com/sierra/agi/res/ResourceProviderZip.java b/core/src/main/java/com/sierra/agi/res/ResourceProviderZip.java new file mode 100644 index 0000000..d6fd800 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceProviderZip.java @@ -0,0 +1,61 @@ +/* + * ResourceProviderZip.java + * Adventure Game Interpreter Resource Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +public abstract class ResourceProviderZip implements ResourceProvider { + protected ZipFile file; + + protected int[] counts; + protected int[][] enums; + + public ResourceProviderZip(File file) throws IOException { + this.file = new ZipFile(file); + } + + public static void convert(File file, ResourceProvider provider) throws ResourceException, IOException { + ZipOutputStream outZip = new ZipOutputStream(new FileOutputStream(file)); + DataOutputStream outData = new DataOutputStream(outZip); + ResourceConfiguration config = provider.getConfiguration(); + + outZip.setLevel(9); + outZip.putNextEntry(new ZipEntry("info")); + + convert(outData, provider, TYPE_LOGIC); + convert(outData, provider, TYPE_PICTURE); + convert(outData, provider, TYPE_SOUND); + convert(outData, provider, TYPE_VIEW); + + outData.writeLong(provider.getCRC()); + outData.writeShort(config.engineEmulation); + outData.writeBoolean(config.amiga); + outData.writeBoolean(config.agds); + outData.writeUTF(config.name); + + outZip.close(); + } + + protected static void convert(DataOutputStream outData, ResourceProvider provider, byte resType) throws ResourceException, IOException { + short[] en = provider.enumerate(resType); + int index; + + outData.writeInt(en.length); + + for (index = 0; index < en.length; index++) { + outData.writeShort(en[index]); + } + } +} diff --git a/core/src/main/java/com/sierra/agi/res/ResourceTypeInvalidException.java b/core/src/main/java/com/sierra/agi/res/ResourceTypeInvalidException.java new file mode 100644 index 0000000..542e4c5 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/ResourceTypeInvalidException.java @@ -0,0 +1,32 @@ +/** + * ResourceTypeInvalidException.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +/** + * Resource type passed by parameter is invalid. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class ResourceTypeInvalidException extends ResourceException { + /** + * Creates new ResourceTypeInvalidException without detail message. + */ + public ResourceTypeInvalidException() { + } + + /** + * Constructs an ResourceTypeInvalidException with the specified detail message. + * + * @param msg Detail message. + */ + public ResourceTypeInvalidException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/VolumeNotFoundException.java b/core/src/main/java/com/sierra/agi/res/VolumeNotFoundException.java new file mode 100644 index 0000000..7c4ff2a --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/VolumeNotFoundException.java @@ -0,0 +1,33 @@ +/** + * NoVolumeAvailableException.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res; + +/** + * The volume is not found. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public final class VolumeNotFoundException extends ResourceException { + /** + * Creates new VolumeNotFoundException without detail message. + */ + public VolumeNotFoundException() { + super(); + } + + /** + * Constructs an VolumeNotFoundException with the specified detail message. + * + * @param msg Detail message. + */ + public VolumeNotFoundException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/dir/ResourceDirectory.java b/core/src/main/java/com/sierra/agi/res/dir/ResourceDirectory.java new file mode 100644 index 0000000..daf9969 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/dir/ResourceDirectory.java @@ -0,0 +1,127 @@ +/** + * ResourceDirectory.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res.dir; + +import com.sierra.agi.io.IOUtils; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + *

Directories
+ * Each directory file is of the same format. They contain a finite number + * of three byte entries, no more than 256. The size will vary depending on the + * number of files of the type that the directory file is pointing to. Dividing + * the filesize by three gives the maximum file number of that type of data + * file. Each entry is of the following format:

+ *
+ * Byte 1          Byte 2          Byte 3 
+ * 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 
+ * V V V V P P P P P P P P P P P P P P P P P P P P 
+ *
+ * V = VOL number.
+ * P = Position (offset into VOL file)
+ *

+ * The entry number itself gives the number of the data file that it is + * pointing to. For example, if the following three byte entry is entry + * number 45 in the SOUND directory file, 12 3D FE + * then SOUND.45 is located at position 0x23DFE in the VOL.1 file. The first + * entry number is entry 0. + *

+ * If the three bytes contain the value 0xFFFFFF, then the resource does not + * exist.

+ * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class ResourceDirectory { + /** Maximum number of entries in a directory. */ + protected static final int MAX_ENTRIES = 256; + + /** Directory Entries */ + protected int[] entries = new int[MAX_ENTRIES]; + + /** Directory CRC */ + protected int crc; + + /** Directory */ + protected int count; + + /** Creates a new Resource Directory */ + public ResourceDirectory(InputStream in) throws IOException { + byte[] b = new byte[3]; + int i = 0, j = 0, e; + int c = 0, n = 0; + + try { + while (true) { + IOUtils.fill(in, b, 0, 3); + + for (j = 0; j < 3; j++) { + c += (b[j] & 0xff); + } + + if (b[0] != -1) { + entries[i] = ((b[0] & 0xf0) >> 4) << 24 | ((b[0] & 0x0f) << 16) | ((b[1] & 0xff) << 8) | (b[2] & 0xff); + n++; + } else { + entries[i] = -1; + } + + i++; + } + } catch (EOFException ex) { + } + + for (; i < MAX_ENTRIES; i++) { + entries[i] = -1; + } + + crc = c; + count = n; + } + + public int getCRC() { + return crc; + } + + public int getVolume(int resourceNumber) { + if (entries[resourceNumber] == -1) { + return -1; + } + + return (entries[resourceNumber] & 0xff000000) >> 24; + } + + public int getOffset(int resourceNumber) { + if (entries[resourceNumber] == -1) { + return -1; + } + + return (entries[resourceNumber] & 0x00ffffff); + } + + public int getCount() { + return count; + } + + public short[] getNumbers() { + short[] numbers = new short[count]; + short i, j; + + for (i = 0, j = 0; (i < 256) && (j < count); i++) { + if (entries[i] != -1) { + numbers[j++] = i; + } + } + + return numbers; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/v2/ResourceProviderV2.java b/core/src/main/java/com/sierra/agi/res/v2/ResourceProviderV2.java new file mode 100644 index 0000000..30a471a --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/v2/ResourceProviderV2.java @@ -0,0 +1,566 @@ +/** + * ResourceProviderV2.java + * Adventure Game Interpreter Resource Package + *

+ * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.res.v2; + +import com.sierra.agi.io.ByteCaster; +import com.sierra.agi.io.ByteCasterStream; +import com.sierra.agi.io.CryptedInputStream; +import com.sierra.agi.io.SegmentedInputStream; +import com.sierra.agi.res.CorruptedResourceException; +import com.sierra.agi.res.NoDirectoryAvailableException; +import com.sierra.agi.res.NoVolumeAvailableException; +import com.sierra.agi.res.ResourceConfiguration; +import com.sierra.agi.res.ResourceException; +import com.sierra.agi.res.ResourceNotExistingException; +import com.sierra.agi.res.ResourceProvider; +import com.sierra.agi.res.ResourceTypeInvalidException; +import com.sierra.agi.res.dir.ResourceDirectory; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Properties; + +/** + * Provide access to resources via the standard storage methods. + * It reads unmodified sierra's resource files. + *

+ * All AGI games have either one directory file, or more commonly, four. + * AGI version 2 games will have the files LOGDIR, PICDIR, VIEWDIR, and SNDDIR. + * This single file is basically the four version 2 files joined together + * except that it has an 8 byte header giving the position of each directory + * within the single file. + *

+ * The directory files give the location of the data types within the VOL + * files. The type of directory determines the type of data. For example, the + * LOGDIR gives the locations of the LOGIC files. + * + * Note: In this description and elsewhere in documents written by me, + * the AGI data called LOGIC, PICTURE, VIEW, and SOUND data are referred to by + * me as files even though they are part of a single VOL file. I think of + * the VOL file as sort of a virtual storage device in itself that holds many + * files. Some documents call the files contains in VOL files "resources". + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class ResourceProviderV2 implements ResourceProvider { + /** + * AGDS's Decryption Key. This key is used to decrypt + * AGDS games. + */ + public static final String AGDS_KEY = "Alex Simkin"; + /** + * Sierra's Decryption Key. This key used to decrypt + * original sierra games. + */ + public static final String SIERRA_KEY = "Avis Durgan"; + /** + * Resource's CRC. + */ + protected long crc; + /** + * Resource's Entries Tables. + */ + protected ResourceDirectory[] entries = new ResourceDirectory[4]; + /** + * Path to resources files. + */ + protected File path; + /** + * Resource Configuration + */ + protected ResourceConfiguration configuration = new ResourceConfiguration(); + + protected String version = "unknown"; + + /** + * Initialize the ResourceProvider implementation to access + * resource on the file system. + * + * @param folder Resource's folder or File inside the resource's + * folder. + */ + public ResourceProviderV2(File folder) throws IOException, ResourceException { + if (!folder.exists()) { + throw new FileNotFoundException(); + } + + if (folder.isDirectory()) { + path = folder.getAbsoluteFile(); + } else { + path = folder.getParentFile(); + } + + readVolumes(); + readDirectories(); + readVersion(); + calculateCRC(); + calculateConfiguration(); + } + + public static String getKey(boolean agds) { + return System.getProperty("com.sierra.agi.res.key", agds ? AGDS_KEY : SIERRA_KEY); + } + + public static boolean isCrypted(File file) { + boolean b = false; + + try { + ByteCasterStream bstream = new ByteCasterStream(new FileInputStream(file)); + + if (bstream.lohiReadUnsignedShort() > file.length()) { + b = true; + } + + bstream.close(); + return b; + } catch (Throwable t) { + return false; + } + } + + protected void validateType(byte resType) throws ResourceTypeInvalidException { + if ((resType > TYPE_WORD) || (resType < TYPE_LOGIC)) { + throw new ResourceTypeInvalidException(); + } + } + + /** + * Retreive the count of resources of the specified type. + * Only valid with Locic, Picture, Sound and View resource + * types. + * + * @param resType Resource type + * @return Resource count. + * @see com.sierra.agi.res.ResourceProvider#TYPE_LOGIC + * @see com.sierra.agi.res.ResourceProvider#TYPE_PICTURE + * @see com.sierra.agi.res.ResourceProvider#TYPE_SOUND + * @see com.sierra.agi.res.ResourceProvider#TYPE_VIEW + */ + public int count(byte resType) throws ResourceException { + validateType(resType); + + if (resType >= TYPE_OBJECT) { + return 1; + } + + return entries[resType].getCount(); + } + + /** + * Enumerate the resource numbers of the specified type. + * Only valid with Locic, Picture, Sound and View resource + * types. + * + * @param resType Resource type + * @return Array containing the resource numbers. + * @see com.sierra.agi.res.ResourceProvider#TYPE_LOGIC + * @see com.sierra.agi.res.ResourceProvider#TYPE_PICTURE + * @see com.sierra.agi.res.ResourceProvider#TYPE_SOUND + * @see com.sierra.agi.res.ResourceProvider#TYPE_VIEW + */ + public short[] enumerate(byte resType) throws ResourceException { + validateType(resType); + + return entries[resType].getNumbers(); + } + + /** + * Open the specified resource and return a pointer + * to the resource. The InputStream is decrypted/decompressed, + * if neccessary, by this function. (So you don't have to care + * about them.) + * + * @param resType Resource type + * @param resNumber Resource number. Ignored if resource type + * is TYPE_OBJECT or + * TYPE_WORD + * @return InputStream linked to the specified resource. + * @see com.sierra.agi.res.ResourceProvider#TYPE_LOGIC + * @see com.sierra.agi.res.ResourceProvider#TYPE_OBJECT + * @see com.sierra.agi.res.ResourceProvider#TYPE_PICTURE + * @see com.sierra.agi.res.ResourceProvider#TYPE_SOUND + * @see com.sierra.agi.res.ResourceProvider#TYPE_VIEW + * @see com.sierra.agi.res.ResourceProvider#TYPE_WORD + */ + public InputStream open(byte resType, short resNumber) throws ResourceException, IOException { + File volf; + + switch (resType) { + case ResourceProvider.TYPE_OBJECT: + volf = getDirectoryFile(resType); + + if (isCrypted(volf)) { + return new CryptedInputStream(new FileInputStream(volf), getKey(false)); + } else { + return new FileInputStream(volf); + } + + case ResourceProvider.TYPE_WORD: + return new FileInputStream(getDirectoryFile(resType)); + } + + try { + if (entries[resType] != null) { + int vol, offset, length; + + vol = entries[resType].getVolume(resNumber); + offset = entries[resType].getOffset(resNumber); + + if ((vol != -1) && (offset != -1)) { + byte[] b; + RandomAccessFile file; + InputStream in; + + b = new byte[5]; + file = new RandomAccessFile(getVolumeFile(vol), "r"); + file.seek(offset); + file.read(b, 0, 5); + + if ((b[0] != 0x12) || (b[1] != 0x34)) { + throw new CorruptedResourceException(); + } + + length = ByteCaster.lohiUnsignedShort(b, 3); + in = new SegmentedInputStream(file, offset + 5, length); + + if (resType == TYPE_LOGIC) { + int startPos, numMessages, offsetCrypted; + + // Calculate the Messages Offset + file.read(b, 0, 2); + startPos = ByteCaster.lohiUnsignedShort(b, 0) + 2; + file.seek(offset + startPos + 5); + file.read(b, 0, 3); + numMessages = ByteCaster.lohiUnsignedByte(b, 0); + offsetCrypted = startPos + 3 + (numMessages * 2); + file.seek(offset + 5); + + in = new CryptedInputStream(in, getKey(false), offsetCrypted); + } + + return in; + } + } + + throw new ResourceNotExistingException(); + } catch (IndexOutOfBoundsException e) { + throw new ResourceTypeInvalidException(); + } + } + + /** + * Calculate the CRC of the resources. In this implentation + * the CRC is not calculated by this function, it only return + * the cached CRC value. + * + * @return CRC of the resources. + */ + public long getCRC() { + return crc; + } + + public byte getProviderType() { + return PROVIDER_TYPE_FAST; + } + + public ResourceConfiguration getConfiguration() { + return configuration; + } + + protected File getVolumeFile(int vol) throws IOException { + File file = getGameFile(path, "vol." + vol); + + if (!file.exists()) { + throw new FileNotFoundException("File " + file.getPath() + " can't be found."); + } + + return file; + } + + /** + * To account for different platforms, where there may or may not be a case + * sensitive file system, and where the game files may or may not have been + * copied from another platform, we attempt here to look for the requested + * game file firstly as-is, then in uppercase form, and then in lowercase. + * + * @param path The File that represents the folder that contains the game files. + * @param fileName The name of the file to get. + * @return A File representing the game file. + */ + protected File getGameFile(File path, String fileName) { + File file = new File(path, fileName); + if (!file.exists()) { + file = new File(path, fileName.toUpperCase()); + if (!file.exists()) { + file = new File(path, fileName.toLowerCase()); + } + } + return file; + } + + protected File getDirectoryFile(byte resType) throws IOException { + File file; + + switch (resType) { + case ResourceProvider.TYPE_OBJECT: + file = getGameFile(path, "object"); + break; + case ResourceProvider.TYPE_WORD: + file = getGameFile(path, "words.tok"); + break; + case ResourceProvider.TYPE_LOGIC: + file = getGameFile(path, "logdir"); + break; + case ResourceProvider.TYPE_PICTURE: + file = getGameFile(path, "picdir"); + break; + case ResourceProvider.TYPE_SOUND: + file = getGameFile(path, "snddir"); + break; + case ResourceProvider.TYPE_VIEW: + file = getGameFile(path, "viewdir"); + break; + default: + return null; + } + + if (!file.exists()) { + throw new FileNotFoundException(); + } + + return file; + } + + /** + * Retreive the size in bytes of the specified resource. + * + * @param resType Resource type + * @param resNumber Resource number. Ignored if resource type + * is TYPE_OBJECT or + * TYPE_WORD + * @return Size in bytes of the specified resource. + * @see com.sierra.agi.res.ResourceProvider#TYPE_LOGIC + * @see com.sierra.agi.res.ResourceProvider#TYPE_OBJECT + * @see com.sierra.agi.res.ResourceProvider#TYPE_PICTURE + * @see com.sierra.agi.res.ResourceProvider#TYPE_SOUND + * @see com.sierra.agi.res.ResourceProvider#TYPE_VIEW + * @see com.sierra.agi.res.ResourceProvider#TYPE_WORD + */ + public int getSize(byte resType, short resNumber) throws ResourceException, IOException { + switch (resType) { + case ResourceProvider.TYPE_OBJECT: + case ResourceProvider.TYPE_WORD: + return (int) getDirectoryFile(resType).length(); + } + + try { + if (entries[resType] != null) { + int vol, offset; + + vol = entries[resType].getVolume(resNumber); + offset = entries[resType].getOffset(resNumber); + + if ((vol != -1) && (offset != -1)) { + byte[] b; + RandomAccessFile file; + + b = new byte[5]; + file = new RandomAccessFile(getVolumeFile(vol), "r"); + file.seek(offset); + file.read(b); + file.close(); + + if ((b[0] != 0x12) || (b[1] != 0x34)) { + throw new CorruptedResourceException(); + } + + return ByteCaster.lohiUnsignedShort(b, 3); + } + } + + throw new ResourceNotExistingException(); + } catch (IndexOutOfBoundsException e) { + throw new ResourceTypeInvalidException(); + } + } + + /** + * Find volumes files + */ + protected void readVolumes() throws NoVolumeAvailableException { + boolean founded = false; + int i = 0; + File volf; + + while (true) { + volf = getGameFile(path, "vol." + i); + + if (volf.exists()) { + founded = true; + break; + } + + if (i > 50) { + break; + } + + i++; + } + + if (!founded) { + throw new NoVolumeAvailableException(); + } + } + + /** + * Read all directory files + */ + protected void readDirectories() throws NoDirectoryAvailableException, IOException { + byte i; + int j; + File dir; + InputStream stream; + + for (i = 0, j = 0; i < 4; i++) { + dir = getDirectoryFile(i); + + if (dir != null) { + stream = new FileInputStream(dir); + entries[i] = new ResourceDirectory(stream); + stream.close(); + j++; + } + } + + if (j == 0) { + throw new NoDirectoryAvailableException(); + } + } + + /** + * Calculate the Resource's CRC + */ + protected void calculateCRC() throws IOException { + File dirf = new File(path, "vol.crc"); + + try { + /* Check if the CRC has been pre-calculated */ + DataInputStream meta = new DataInputStream(new FileInputStream(dirf)); + + crc = meta.readLong(); + meta.close(); + } catch (IOException ex) { + /* CRC need to be calculated from scratch */ + crc = calculateCRCFromScratch(); + + /* Write down the CRC for next times */ + DataOutputStream meta = new DataOutputStream(new FileOutputStream(dirf)); + + meta.writeLong(crc); + meta.close(); + } + } + + protected int calculateCRCFromScratch() throws IOException { + File[] dir; + int i, j, c; + + c = 0; + dir = new File[2]; + dir[0] = getGameFile(path, "object"); + dir[1] = getGameFile(path, "words.tok"); + + for (i = 0; i < entries.length; i++) { + if (entries[i] != null) { + c += entries[i].getCRC(); + } + } + + for (i = 0; i < dir.length; i++) { + FileInputStream stream = new FileInputStream(dir[i]); + + while (true) { + j = stream.read(); + + if (j == -1) + break; + + c += j; + } + + stream.close(); + } + + return c; + } + + protected void calculateConfiguration() { + Properties props = new Properties(); + String scrc = "0x" + Long.toString(crc, 16); + + String ver = props.getProperty(scrc, "0x2917"); + configuration.amiga = ver.indexOf('a') != -1; + configuration.agds = ver.indexOf('g') != -1; + ver = ver.substring(2); + + while (!Character.isDigit(ver.charAt(ver.length() - 1))) { + ver = ver.substring(0, ver.length() - 1); + } + + configuration.engineEmulation = (Integer.valueOf(ver, 16).shortValue()); + + props = new Properties(); + + configuration.name = props.getProperty(scrc, "Unknown Game"); + } + + public File getPath() { + return this.path; + } + + private void readVersion() throws IOException { + File file = getGameFile(path, "agidata.ovl"); + + if (!file.exists()) { + return; + } + + byte[] fileContent = Files.readAllBytes(file.toPath()); + + for (int i = 0; i < fileContent.length; i++) { + if (fileContent[i] == 86 && fileContent[i + 1] == 101 && fileContent[i + 2] == 114 && fileContent[i + 3] == 115 && fileContent[i + 4] == 105 && fileContent[i + 5] == 111 && fileContent[i + 6] == 110 && fileContent[i + 7] == 32) { + int j; + for (j = i + 8; fileContent[j] != 0; j++) { + } + + this.version = new String(fileContent, i + 8, j - (i + 8), StandardCharsets.US_ASCII); + break; + } + } + } + + public String getVersion() { + return this.version; + } + + public String getV3GameSig() { + // This is V2, so it doesn't apply. Return null; + return null; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/res/v3/ResourceProviderV3.java b/core/src/main/java/com/sierra/agi/res/v3/ResourceProviderV3.java new file mode 100644 index 0000000..66ebda2 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/res/v3/ResourceProviderV3.java @@ -0,0 +1,385 @@ +/* + * ResourceProviderV3.java + */ + +package com.sierra.agi.res.v3; + +import com.sierra.agi.io.*; +import com.sierra.agi.res.*; +import com.sierra.agi.res.dir.ResourceDirectory; + +import java.io.*; +import java.util.Arrays; + +/** + * Provide access to resources via the standard storage methods. + * It reads unmodified sierra's resource files. + *

+ * AGIv3 stores resources in a slightly different way from AGIv2. The first + * significant difference is in the length of the resource header which is + * now seven bytes. + *

+ * + * + * + * + * + * + *
ByteMeaning
0-1Signature (0x12--0x34)
2Vol number that the resource is contained in
3-4Uncompressed resource size (LO-HI)
5-6Compressed resource size (LO-HI)
+ *

+ * Instead of one resource size as in AGIv2, there are now two sizes. Most of + * the resources in AGIv3 games are compressed with a form of LZW. Some of them + * are not though. The interpreter determines whether the resource is compressed + * by comparing the values of the two sizes given in the header information. If + * they are equal, then it knows that the resource is stored uncompressed. + * However, if the sizes do not match, this does not mean that the file is + * compressed with LZW. If the file is a PICTURE file, then it is stored with + * its own limited form of compression. This is why the top bit of the third + * byte in the header is used to tell the interpreter that the resource is a + * PICTURE file, otherwise it would think that the resource was compressed with + * LZW. + *

+ * As far as I can tell, none of the PICTUREs are compressed with LZW. This may + * well be possible though. It could also be possible for the PICTURE to be + * totally uncompressed (i.e. it wouldn't use the PICTURE compression method), + * but I haven't seen any examples of either of the above two cases. + * + *

LZW compression + *

+ * The compression used with version 3 games is an adaptive form of LZW. The LZW + * algorithm is not explained here, but it basically compresses data by + * representing previous strings by single codes. When these strings are + * encountered again, the code can be stored instead. The following information + * states how the AGIv3 algorithm differs from the standard LZW algorithm. There + * are plenty of places on the net where you can find a description of the LZW + * algorithm if you are not familiar with it. + *

+ * AGIv3 uses an adaptive form of LZW that starts by using 9 bit codes and when + * the code space is full, it progresses on to 10 bits and so on. As with normal + * LZW, codes 0-255 represent the standard ASCII characters. The next two codes + * have a special meaning: + *

+ * 256 is used as a start over code. The table is cleared, the number of bits + * set back to 9, and the process begins again with the next code being 258. + *

+ * 257 tells the interpreter that it has reached the end of the resource. + *

+ * Code 256 seems to be the first code stored in all compressed resources. This + * is probably just to make sure everything is initialized for beginning the + * compression process. As was mentioned above, the first code used for the LZW + * table itself is code 258. From there it stores pairs of prefix codes and + * appended characters for each table entry until it reaches code 512 at which + * stage it switches to storing the codes using 10 bits and then 11 and so on. + * It appears that it will never get to 12 bits because code 256 always seems + * to turn up just before it needs to switch up to 12 bits, i.e. when code 2048 + * is required. Carl Muckenhoupt's decrypt routine for SCI games specifically + * prevents it from switching to 12 bits anyway. Whether there is ever a case + * where code 256 does not intervene, it has not yet been determined. + *

+ * Note: I should point out that Carl and myself both arrived at the above + * algorithm independently which confirms that the compression used in the early + * SCI games was identical to that used in AGIv3. + *

+ * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class ResourceProviderV3 extends com.sierra.agi.res.v2.ResourceProviderV2 { + protected File[] vols; + protected File[] dirs; + + /** + * Initialize the ResourceProvider implentation to access + * resource on the file system. + * + * @param folder Resource's folder or File inside the resource's + * folder. + */ + public ResourceProviderV3(File folder) throws IOException, ResourceException { + super(folder); + } + + /** + * Find volumes files + */ + protected void readVolumes() throws NoVolumeAvailableException { + vols = path.listFiles(new VolumeFilenameFilter()); + + if (vols == null) { + throw new NoVolumeAvailableException(); + } + + if (vols.length == 0) { + throw new NoVolumeAvailableException(); + } + + Arrays.sort(vols, new VolumeSorter()); + } + + protected int calculateCRCFromScratch() throws IOException { + byte[] b = new byte[8]; + int c, i; + InputStream stream; + + c = super.calculateCRCFromScratch(); + + stream = new FileInputStream(dirs[0]); + stream.read(b, 0, 8); + stream.close(); + + for (i = 0; i < 8; i++) { + c += (b[i] & 0xff); + } + + return c; + } + + /** + * Read directory files + */ + protected void readDirectories() throws NoDirectoryAvailableException, IOException { + byte[] b = new byte[8]; + int[] o = new int[8]; + InputStream stream; + RandomAccessFile dirfile; + int i, j, ax; + + findDirectories(); + stream = new FileInputStream(dirs[0]); + stream.read(b, 0, 8); + stream.close(); + + for (i = 0; i < 4; i++) { + o[i] = ByteCaster.lohiUnsignedShort(b, i * 2); + } + + for (i = 0; i < 4; i++) { + ax = 0xffffff; + + for (j = 0; j < 4; j++) { + if ((o[j] > o[i]) && (o[j] < ax)) { + ax = o[j]; + } + } + + if (ax == 0xffffff) { + ax = (int) dirs[0].length(); + } + + o[i + 4] = ax; + } + + dirfile = new RandomAccessFile(dirs[0], "r"); + o[4] -= o[0]; + entries[0] = new ResourceDirectory(new SegmentedInputStream(dirfile, o[0], o[4])); + + o[5] -= o[1]; + entries[1] = new ResourceDirectory(new SegmentedInputStream(dirfile, o[1], o[5])); + + o[6] -= o[2]; + entries[3] = new ResourceDirectory(new SegmentedInputStream(dirfile, o[2], o[6])); + + o[7] -= o[3]; + entries[2] = new ResourceDirectory(new SegmentedInputStream(dirfile, o[3], o[7])); + } + + /** + * Find all directory files + */ + protected void findDirectories() throws NoDirectoryAvailableException { + dirs = path.listFiles(new DirectoryFilenameFilter()); + + if (dirs == null) { + throw new NoDirectoryAvailableException(); + } + + if (dirs.length == 0) { + throw new NoDirectoryAvailableException(); + } + + Arrays.sort(dirs, new DirectorySorter()); + } + + /** + * Open the specified resource and return a pointer + * to the resource. The InputStream is decrypted/decompressed, + * if neccessary, by this function. (So you don't have to care + * about them.) + * + * @param resType Resource type + * @param resNumber Resource number. Ignored if resource type + * is TYPE_OBJECT or + * TYPE_WORD + * @return InputStream linked to the specified resource. + * @see com.sierra.agi.res.ResourceProvider#TYPE_LOGIC + * @see com.sierra.agi.res.ResourceProvider#TYPE_OBJECT + * @see com.sierra.agi.res.ResourceProvider#TYPE_PICTURE + * @see com.sierra.agi.res.ResourceProvider#TYPE_SOUND + * @see com.sierra.agi.res.ResourceProvider#TYPE_VIEW + * @see com.sierra.agi.res.ResourceProvider#TYPE_WORD + */ + public InputStream open(byte resType, short resNumber) throws IOException, ResourceException { + if (resType > TYPE_WORD) { + throw new ResourceTypeInvalidException(); + } + + switch (resType) { + case ResourceProvider.TYPE_OBJECT: + if (isCrypted(dirs[1])) { + return new CryptedInputStream(new FileInputStream(dirs[1]), getKey(false)); + } + + return new FileInputStream(dirs[1]); + + case ResourceProvider.TYPE_WORD: + return new FileInputStream(dirs[2]); + } + + try { + if (entries[resType] != null) { + int vol, offset, compressed, uncompressed; + + vol = entries[resType].getVolume(resNumber); + offset = entries[resType].getOffset(resNumber); + + if ((vol != -1) && (offset != -1)) { + byte[] b; + RandomAccessFile file; + InputStream in; + + try { + b = new byte[7]; + file = new RandomAccessFile(vols[vol], "r"); + file.seek(offset); + file.read(b, 0, 7); + } catch (IndexOutOfBoundsException ioobex) { + throw new ResourceNotExistingException(); + } + + if ((b[0] != 0x12) || (b[1] != 0x34)) { + throw new CorruptedResourceException(); + } + + uncompressed = ByteCaster.lohiUnsignedShort(b, 3); + compressed = ByteCaster.lohiUnsignedShort(b, 5); + in = new SegmentedInputStream(file, offset + 7, compressed); + + if (resType == TYPE_PICTURE) { + in = new PictureInputStream(in); + } else { + if (compressed != uncompressed) { + in = new LZWInputStream(in); + } + } + + return in; + } + } + } catch (IndexOutOfBoundsException ioobex) { + throw new ResourceTypeInvalidException(); + } + + throw new ResourceNotExistingException(); + } + + protected File getVolumeFile(int vol) throws IOException { + File file = vols[vol]; + + if (!file.exists()) { + throw new FileNotFoundException(); + } + + return file; + } + + protected File getDirectoryFile(int resType) throws IOException { + File file; + + switch (resType) { + case ResourceProvider.TYPE_OBJECT: + file = dirs[1]; + break; + case ResourceProvider.TYPE_WORD: + file = dirs[2]; + break; + case ResourceProvider.TYPE_LOGIC: + case ResourceProvider.TYPE_PICTURE: + case ResourceProvider.TYPE_SOUND: + case ResourceProvider.TYPE_VIEW: + file = dirs[0]; + default: + return null; + } + + if (!file.exists()) { + throw new FileNotFoundException(); + } + + return file; + } + + public String getV3GameSig() { + return dirs[0].getName().toUpperCase().replaceAll("DIR$", ""); + } + + protected static class VolumeFilenameFilter implements java.io.FilenameFilter { + public boolean accept(File dir, String name) { + int c; + String s; + + c = name.lastIndexOf('.'); + + if (c == -1) { + return false; + } + + if (!Character.isDigit(name.charAt(c + 1))) { + return false; + } + + s = name.substring(0, c); + s = s.toLowerCase(); + + return s.endsWith("vol"); + } + } + + protected static class VolumeSorter implements java.util.Comparator { + public int compare(Object o1, Object o2) { + return ((File) o1).getName().compareToIgnoreCase(((File) o2).getName()); + } + } + + protected static class DirectoryFilenameFilter implements java.io.FilenameFilter { + public boolean accept(File dir, String name) { + if (name.equalsIgnoreCase("object")) { + return true; + } + + if (name.equalsIgnoreCase("words.tok")) { + return true; + } + + return name.toLowerCase().endsWith("dir"); + } + } + + protected static class DirectorySorter implements java.util.Comparator { + public int compare(Object o1, Object o2) { + String s1 = ((File) o1).getName(); + String s2 = ((File) o2).getName(); + + if (s1.toLowerCase().endsWith("dir")) { + if (!s2.toLowerCase().endsWith("dir")) { + return -1; + } + } else { + if (s2.toLowerCase().endsWith("dir")) { + return 1; + } + } + + return s1.compareToIgnoreCase(s2); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/sound/Sound.java b/core/src/main/java/com/sierra/agi/sound/Sound.java new file mode 100644 index 0000000..91f0300 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/sound/Sound.java @@ -0,0 +1,12 @@ +/* + * Sound.java + * Adventure Game Interpreter Sound Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.sound; + +public interface Sound { +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/sound/SoundProvider.java b/core/src/main/java/com/sierra/agi/sound/SoundProvider.java new file mode 100644 index 0000000..fe2b0e7 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/sound/SoundProvider.java @@ -0,0 +1,16 @@ +/* + * SoundProvider.java + * Adventure Game Interpreter Sound Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.sound; + +import java.io.IOException; +import java.io.InputStream; + +public interface SoundProvider { + Sound loadSound(InputStream inputStream) throws IOException; +} diff --git a/core/src/main/java/com/sierra/agi/view/Cel.java b/core/src/main/java/com/sierra/agi/view/Cel.java new file mode 100644 index 0000000..7823cdb --- /dev/null +++ b/core/src/main/java/com/sierra/agi/view/Cel.java @@ -0,0 +1,152 @@ +/* + * Cell.java + * Adventure Game Interpreter View Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.view; + +import com.sierra.agi.awt.EgaUtils; +import com.sierra.agi.io.ByteCaster; + +import java.awt.*; +import java.awt.image.ColorModel; +import java.awt.image.DirectColorModel; +import java.awt.image.IndexColorModel; +import java.awt.image.MemoryImageSource; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public class Cel { + /** + * Cell's Width + */ + protected short width; + + /** + * Cell's Height + */ + protected short height; + + /** + * Cell's Data + */ + protected int[] data; + + /** + * Cell's Transparent Color + */ + protected int transparent; + + /** + * Creates new Cell + */ + public Cel(byte[] b, int start, int loopNumber) { + width = ByteCaster.lohiUnsignedByte(b, start); + height = ByteCaster.lohiUnsignedByte(b, start + 1); + short trans = ByteCaster.lohiUnsignedByte(b, start + 2); + + byte transColor = (byte) (trans & 0x0F); + short mirrorInfo = (short) ((trans & 0xF0) >> 4); + + loadData(b, start + 3, transColor); + + if ((mirrorInfo & 0x8) != 0) { + if ((mirrorInfo & 0x7) != loopNumber) { + mirror(); + } + } + } + + protected void loadData(byte[] b, int off, byte transColor) { + int x; + + IndexColorModel indexModel = EgaUtils.getIndexColorModel(); + ColorModel nativeModel = EgaUtils.getNativeColorModel(); + + int[] pixel = new int[1]; + data = new int[width * height]; + + for (int j = 0, y = 0; y < height; y++) { + for (x = 0; b[off] != 0; off++) { + int color = (b[off] & 0xF0) >> 4; + int count = (b[off] & 0x0F); + + for (int i = 0; i < count; i++, j++, x++) { + nativeModel.getDataElements(indexModel.getRGB(color), pixel); + data[j] = pixel[0]; + } + } + + nativeModel.getDataElements(indexModel.getRGB(transColor), pixel); + + for (; x < width; j++, x++) { + data[j] = pixel[0]; + } + + off++; + } + + nativeModel.getDataElements(indexModel.getRGB(transColor), pixel); + transparent = pixel[0]; + } + + protected void mirror() { + for (int y = 0; y < height; y++) { + for (int x1 = width - 1, x2 = 0; x1 > x2; x1--, x2++) { + int i1 = (y * width) + x1; + int i2 = (y * width) + x2; + + int b = data[i1]; + data[i1] = data[i2]; + data[i2] = b; + } + } + } + + public short getWidth() { + return width; + } + + public short getHeight() { + return height; + } + + public int[] getPixelData() { + return data; + } + + public int getTransparentPixel() { + return transparent; + } + + /** + * Obtain an standard Image object that is a graphical representation of the + * cell. + * + * @param context Game context used to generate the image. + */ + public Image getImage() { + int[] data = this.data.clone(); + DirectColorModel colorModel = (DirectColorModel) ColorModel.getRGBdefault(); + DirectColorModel nativeModel = EgaUtils.getNativeColorModel(); + // int mask = colorModel.getAlphaMask(); + int[] pixel = new int[1]; + + for (int i = 0; i < (width * height); i++) { + colorModel.getDataElements(nativeModel.getRGB(data[i]), pixel); + + if (data[i] != transparent) { + data[i] = pixel[0]; + } else { + data[i] = 0x00ffffff; + } + } + + return Toolkit.getDefaultToolkit().createImage(new MemoryImageSource(width, height, colorModel, data, 0, width)); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/view/Loop.java b/core/src/main/java/com/sierra/agi/view/Loop.java new file mode 100644 index 0000000..0c4153e --- /dev/null +++ b/core/src/main/java/com/sierra/agi/view/Loop.java @@ -0,0 +1,51 @@ +/* + * Loop.java + * Adventure Game Interpreter View Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.view; + +import com.sierra.agi.io.ByteCaster; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public class Loop { + /** + * Cells + */ + protected Cel[] cels = null; + + /** + * Creates new Loop + */ + public Loop(Cel[] cels) { + this.cels = cels; + } + + public Loop(byte[] b, int start, int loopNumber) { + short cellCount; + int i, j; + + cellCount = ByteCaster.lohiUnsignedByte(b, start); + cels = new Cel[cellCount]; + + j = start + 1; + for (i = 0; i < cellCount; i++) { + cels[i] = new Cel(b, start + ByteCaster.lohiUnsignedShort(b, j), loopNumber); + j += 2; + } + } + + public Cel getCell(int cellNumber) { + return cels[cellNumber]; + } + + public short getCellCount() { + return (short) cels.length; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/view/StandardViewProvider.java b/core/src/main/java/com/sierra/agi/view/StandardViewProvider.java new file mode 100644 index 0000000..73830c3 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/view/StandardViewProvider.java @@ -0,0 +1,62 @@ +/* + * ViewProvider.java + * Adventure Game Interpreter View Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.view; + +import com.sierra.agi.io.ByteCaster; +import com.sierra.agi.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public class StandardViewProvider implements ViewProvider { + public StandardViewProvider() { + } + + public View loadView(InputStream inputStream, int size) throws IOException, ViewException { + byte[] b; + int i, j; + short loopCount; + Loop[] loops = null; + String description = null; + + b = new byte[size]; + IOUtils.fill(inputStream, b, 0, size); + inputStream.close(); + + loopCount = b[2]; + if ((b[3] != 0) || (b[4] != 0)) { + /* Reads Description */ + int desc = ByteCaster.lohiUnsignedShort(b, 3); + + i = desc; + try { + while (true) { + if (b[i] == 0) { + break; + } + + i++; + } + } catch (IndexOutOfBoundsException e) { + } + + description = new String(b, desc, i - desc, StandardCharsets.US_ASCII); + } + + j = 5; + loops = new Loop[loopCount]; + for (i = 0; i < loopCount; i++) { + loops[i] = new Loop(b, ByteCaster.lohiUnsignedShort(b, j), i); + j += 2; + } + + return new View(loops, description); + } +} diff --git a/core/src/main/java/com/sierra/agi/view/View.java b/core/src/main/java/com/sierra/agi/view/View.java new file mode 100644 index 0000000..e2b8a45 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/view/View.java @@ -0,0 +1,214 @@ +/* + * View.java + * Adventure Game Interpreter View Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.view; + +/** + * View object. + *

+ * View resources contain some of the graphics for the game. Unlike the picture + * resources which are full-screen background images, view resources are smaller + * 'sprites' used in the game, such as animations and objects. They are also + * stored as bitmaps, whereas pictures are stored in vector format. + *

+ * Each view resource consists of one or more 'loops'. Each loop in the resource + * consists of one or more 'cells' (frames). Thus several animations can be + * stored in one view, or a view can just be used for a single image. The + * maximum number of loops supported by the interpreter is 255 (0-254) and the + * maximum number of cells in each is 255 (0-254). + *

+ * View header (7+ bytes)
+ * Note: ls,ms means that the value is a two-byte word, with the least + * significant byte stored first, and the most significant byte stored second, + * e.g. 12 07 is acually 712 (hex) or 1810 (decimal). Most word values in AGI + * are stored like this, but not all. + *

+ * + * + * + * + * + * + * + * + * + * + *
ByteMeaning
0Unknown (always seems to be either 1 or 2)
1Unknown (always seems to be 1)
2Number of loops
3-4Position of description (more on this later) (ls,ms) Both bytes are 0 if there is no description.
5-6Position of first loop (ls,ms)
7-8Position of second loop (if any) (ls,ms)
9-10Position of third loop (if any) (ls,ms)
....
+ *

+ * Note: Two of these loop references CAN point to the same place. This is done + * when you want to use mirroring (more on this later). + *

+ * + * + * + * + * + * + * + *
Loop Header (3+ bytes)
ByteMeaning
0Number of cells in this loop
1-2Position of first cell, relative to start of loop (ls,ms)
3-4Position of second cell (if any), relative to start of loop (ls,ms)
5-6Position of third cell (if any), relative to start of loop (ls,ms)
+ *

+ * + * + * + * + * + * + *
Cell Header (3 bytes)
ByteMeaning
0Width of cell (remember that AGI pixels are 2 normal EGA pixels wide so a cel of width 12 is actually 24 pixels wide on screen)
1Height of cell
2Transparency and cell mirroring
+ *

+ * The first four bits of this byte tell the interpreter how to handle the + * mirroring of this cell (explained later). The last four bits represent the + * transparent color. When the cell is drawn on the screen, any pixels of this + * color will show through to the background. All cells have a transparent color, + * so if you want an opaque cell then you must set the transparent color to one + * that is not used in the cell. + *

+ * Cell data
+ * The actual image data for each cell is stored using RLE (run length encoding) + * compression. This means that instead of having one byte for each single pixel + * (or 1/2 byte as you would use for 16 colors), each byte specifies how many + * pixels there are to be in a row and what colour they are. I will refer to + * these groups of pixels as 'chunks'. + *

+ * This method of compression is not very efficient if there is a lot of single + * pixels in the image (e.g. a view showing static on a TV screen), but in most + * cases it does save a fair amount of space. + *

+ * Each line (not to be confused with a chunk) in the cell consists of several + * bytes of pixel data, then a 0 to end the line. Each byte of pixel data + * represents one chunk. The first four bits determine the colour, and the last + * four bits determine the number of pixels in the chunk. + *
+ * e.g. AX BY CZ 00
+ *
+ * This line will have: X pixels of colour A [AX]
+ *

  • Y pixels of colour B [BY]
  • + *
  • Z pixels of colour C [CZ]
  • + *
  • (then that will be the end of the line) [00]
  • + *

    + * If the color of the last chunk on the line is the transparent color, there is + * no need to store this. For example, if C was the transparent color in the + * above example, you could just write AX BY 00. This also saves some space. + *

    + * Mirroring
    + * Mirroring is when you have one loop where all the cells are a mirror image of + * the corresponding cells in another loop. Although you could do this manually + * by drawing one loop and then copying and pasting all the cells to another loop + * and flipping them horizontally, AGI views provide the ability to have this + * done automatically - you can draw one loop, and have another loop which is + * set as a mirror of this loop. Thus, when you change one loop you change the + * other. This is useful if you have an animation of a character walking left + * and right - you just draw the right-walking animation and have another loop a + * mirror of this which will have the left-walking animation. Another advantage + * of cell mirroring is to save space - it doesn't make much difference these + * days, but back when AGI was written the games were designed to run on 256k + * systems which meant that memory had to be used as efficiently as possible. + *

    + * Mirroring is done by having both loops share the same cell data - you saw + * above that you can have two loop references pointing to the same place. The + * first four bits of the 3rd byte in the header of each cell tell the interpreter + * what is mirrored:
    + *

  • Bit 1 specifies whether or not this cell is mirrored.
  • + *
  • Bits 1, 2 and 3 specify the number of the loop (from 0-7) which is NOT mirrored.
  • + *

    + * When the interpreter goes to display a loop, it looks at the bit 1 and sees + * if it is mirrored or not. If it is, then it checks the loop number - if this + * is NOT the same as the current loop, then it flips the cell before displaying it. + *

    + * Leaving enough room for the mirrored image:
    + * If you have a cell that is mirrored, you need to ensure that the number of + * bytes the cell takes up in the resource is greater than or equal to the number + * of bytes that the flipped version of the cell would take up. + *

    + * The reason for this is that the interpreter loads the view resource into + * memory and works with that for displaying cells, rather than decoding it and + * storing it in memory as a non-compressed bitmap. I assume that it doesn't + * even bother 'decoding' it as such - it probably just draws the chunks of + * color on the screen as they are. When it has to display the flipped version + * of a cell, instead of storing the flipped cell somewhere else in memory, it + * flips the version that is there. So in memory you have the view resource that + * was read from the file, except that some of the cells have been changed. This + * is why there is mirroring information stored in each cell - the interpreter + * has to know what cells have been changed. When it flips a cell, it changes the + * loop number in the 3rd byte of the cell header to the number of the loop it is + * currently displaying the cell for. So when it looks at this number the next + * time for a different loop, it will see that the cell is not the right way + * round for that loop and mirror it again. + *

    + * This process seems very inefficient to me. I don't know why it doesn't just + * draw the chunks of color on the screen back to front. But since it does it + * this way we have to make sure that there is enough room for the flipped cell. + *

    + * It seems that not all versions of the interpreter require this, however - I + * was working with version 2.917 when I was testing this out, but I noticed that + * versoin 2.440 did not require this. I will attempt to try this with all + * different interpreters and provide a list of which ones use this in the next + * version of this document. But it is best to put these bytes in just in case, + * as the views will still work regardless. + *

    + * Description
    + * All the Views in the game that are used as close-ups of inventory items have + * a description. When a player 'examines' these (in some games you can select + * 'see object' from the menu), they will see the first cell of the first loop of + * this view and the description of the object they are examining. This is + * brought up using the show.obj command. The Description is stored in plain + * text, and terminated by a null character. Lines are separated by an 0x0A. + *

    + * + * @author Dr. Z + * @author Lance Ewing (Documentation) + * @version 0.00.00.01 + */ +public class View { + /** + * Inventory objects have descriptions. + */ + protected String description; + + /** + * Loops + */ + protected Loop[] loops; + + /** + * Creates a new View representation. + * + * @param context Game's Context (facultative) + * @param stream View's Data + * @param size View's Size (facultative if the stream.available() returns + * the size of the view.) + * @throws ViewException If error occur when loading while loading, + * ViewException is throwed. + */ + public View(Loop[] loops, String description) throws ViewException { + this.loops = loops; + this.description = description; + } + + /** + * Obtain a specific loop. + * + * @param loopNumber Loop number. + * @return returns the wanted loop object. + */ + public Loop getLoop(short loopNumber) { + return loops[loopNumber]; + } + + /** + * Obtain the loop count. + * + * @return Returns the loop count. + */ + public short getLoopCount() { + return (short) loops.length; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/view/ViewException.java b/core/src/main/java/com/sierra/agi/view/ViewException.java new file mode 100644 index 0000000..6146790 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/view/ViewException.java @@ -0,0 +1,30 @@ +/* + * ViewException.java + * Adventure Game Interpreter View Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.view; + +/** + * @author Dr. Z + * @version 0.00.00.01 + */ +public class ViewException extends Exception { + /** + * Creates new ViewException without detail message. + */ + public ViewException() { + } + + /** + * Constructs an ViewException with the specified detail message. + * + * @param msg the detail message. + */ + public ViewException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/view/ViewProvider.java b/core/src/main/java/com/sierra/agi/view/ViewProvider.java new file mode 100644 index 0000000..3efbbbb --- /dev/null +++ b/core/src/main/java/com/sierra/agi/view/ViewProvider.java @@ -0,0 +1,16 @@ +/* + * ViewProvider.java + * Adventure Game Interpreter View Package + * + * Created by Dr. Z. + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.view; + +import java.io.IOException; +import java.io.InputStream; + +public interface ViewProvider { + View loadView(InputStream inputStream, int size) throws IOException, ViewException; +} diff --git a/core/src/main/java/com/sierra/agi/word/Word.java b/core/src/main/java/com/sierra/agi/word/Word.java new file mode 100644 index 0000000..f5157a5 --- /dev/null +++ b/core/src/main/java/com/sierra/agi/word/Word.java @@ -0,0 +1,35 @@ +/* + * Word.java + * Adventure Game Interpreter Word Package + * + * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.word; + +/** + * Represent a word. + * + * @author Dr. Z + * @version 0.00.00.01 + */ +public class Word implements Comparable { + /** + * Word number. + */ + public int number; + + /** + * Word textual representation. + */ + public String text; + + public String toString() { + return text + " (" + number + ")"; + } + + public int compareTo(Object o) { + return text.compareTo(((Word) o).text); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/word/Words.java b/core/src/main/java/com/sierra/agi/word/Words.java new file mode 100644 index 0000000..ce9c41b --- /dev/null +++ b/core/src/main/java/com/sierra/agi/word/Words.java @@ -0,0 +1,287 @@ +/* + * Words.java + * Adventure Game Interpreter Word Package + * + * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.word; + +import com.sierra.agi.io.ByteCasterStream; +import com.sierra.agi.io.IOUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; + +/** + * Stores Words of the game. + *

    + * Word File Format
    + * The words.tok file is used to store the games vocabulary, i.e. the dictionary + * of words that the interpreter understands. These words are stored along with + * a word number which is used by the said test commands as argument values for + * that command. Many words can have the same word number which basically means + * that these words are synonyms for each other as far as the game is concerned. + *

    + * The file itself is both packed and encrypted. Words are stored in alphabetic + * order which is required for the compression method to work. + *

    + * The first section
    + * At the start of the file is a section that is always 26x2 bytes long. This + * section contains a two byte entry for every letter of the alphabet. It is + * essentially an index which gives the starting location of the words beginning + * with the corresponding letter. + *

    + * + * + * + * + * + * + * + *
    ByteMeaning
    0-1Hi and then Lo byte for 'A' offset
    ...
    50-51Hi and then Lo byte for 'Z' offset
    52Words section
    + *

    + * The important thing to note from the above is that the 16 bit words are + * big-endian (HI-LO). The little endian (LO-HI) byte order convention used + * everywhere else in the AGI system is not used here. For example, 0x00 and + * 0x24 means 0x0024, not 0x2400. Big endian words are used later on for word + * numbers as well. + *

    + * All offsets are taken from the beginning of the file. If no words start with + * a particular letter, then the offset in that field will be 0x0000. + *

    + * The words section
    + * Words are stored in a compressed way in which each word will use part of the + * previous word as a starting point for itself. For example, "forearm" and + * "forest" both have the prefix "fore". If "forest" comes immediately after + * "forearm", then the data for "forest" will specify that it will start with + * the first four characters of the previous word. Whether this method is used + * for further confusion for would be cheaters or whether it is to help in the + * searching process, I don't yet know, but it most certainly isn't purely for + * compression since the words.tok file is usally quite small and no attempt is + * made to compress any of the larger files (before AGI version 3 that is). + *

    + * + * + * + * + * + * + * + * + * + *
    ByteMeaning
    0Number of characters to include from start of prevous word
    1Char 1 (xor 0x7F gives the ASCII code for the character)
    2Char 2
    ...
    nLast char
    n + 1Wordnum (LO-HI) -- see below
    + *

    + * If a word does not use any part of the previous word, then the prefix field + * is equal to zero. This will always be the case for the first word starting + * with a new letter. There is nothing to indicate where the words starting with + * one letter finish and the next set starts, infact the words section is just + * one continuous chain of words conforming to the above format. The index + * section mentioned earlier is not needed to read the words in which suggests + * that the whole words.tok format is organised to find words quickly. + *

    + * A note about word numbers
    + * Some word numbers have special meaning. They are listed below: + *

    + * + * + * + * + * + * + *
    Word #Meaning
    0Words are ignored (e.g. the, at)
    1Anyword
    9999ROL (Rest Of Line) -- it does matter what the rest of the input list is
    + *

    + * + * @author Dr. Z, Lance Ewing (Documentation) + * @version 0.00.00.01 + */ +public class Words implements WordsProvider { + protected Map wordHash = new HashMap(800); + + protected Map wordNumToWordMap = new HashMap(); + + /** + * Creates a new Word container. + */ + public Words() { + } + + private static String removeSpaces(String inputString) { + StringBuffer buff = new StringBuffer(inputString.length()); + StringTokenizer token = new StringTokenizer(inputString.trim(), " "); + + while (token.hasMoreTokens()) { + buff.append(token.nextToken()); + + if (token.hasMoreTokens()) { + buff.append(" "); + } + } + + return buff.toString(); + } + + private static int findChar(String str, int begin) { + int ch = str.indexOf(' ', begin); + + if (ch < 0) { + ch = str.length(); + } + + return ch; + } + + public Words loadWords(InputStream stream) throws IOException { + loadWordTable(stream); + return this; + } + + /** + * Read a AGI word table. + * + * @param stream Stream from where to read the words. + * @return Returns the number of words readed. + */ + protected int loadWordTable(InputStream stream) throws IOException { + ByteCasterStream bstream = new ByteCasterStream(stream); + String prev = null; + String curr; + int i, wordNum, wordCount; + + IOUtils.skip(stream, 52); + wordCount = 0; + + while (true) { + i = stream.read(); + + if (i < 0) { + break; + } else if (i > 0) { + curr = prev.substring(0, i); + } else { + curr = ""; + } + + while (true) { + i = stream.read(); + + if (i <= 0) { + break; + } else { + curr += (char) ((i ^ 0x7F) & 0x7F); + + if (i >= 0x7F) { + break; + } + } + } + + if (i <= 0) { + break; + } + + wordNum = bstream.hiloReadUnsignedShort(); + prev = curr; + + addWord(wordNum, curr); + wordCount++; + } + + return wordCount; + } + + private boolean addWord(int wordNum, String word) { + Word w = wordHash.get(word); + + if (w != null) { + return false; + } + + w = new Word(); + w.number = wordNum; + w.text = word; + + // Map of word text to the Word object. + wordHash.put(word, w); + + // Map of word number to the Word object. + wordNumToWordMap.put(wordNum, w); + + return true; + } + + public Word getWordByNumber(int wordNum) { + return wordNumToWordMap.get(wordNum); + } + + public Word findWord(String word) { + return wordHash.get(word); + } + + public int getWordCount() { + return wordHash.size(); + } + + public Collection words() { + return wordHash.values(); + } + + public List parse(String inputString) { + List vector = new ArrayList(5); + int begin, end; + Word word; + + inputString = inputString.toLowerCase(); + inputString = removeSpaces(inputString); + begin = 0; + + while (inputString.length() > 0) { + end = findChar(inputString, begin); + word = findWord(inputString.substring(0, end)); + + if (word != null) { + begin = 0; + + try { + inputString = inputString.substring(end + 1); + } catch (StringIndexOutOfBoundsException sioobex) { + inputString = ""; + } + + if (word.number == 9999) { + return vector; + } + + if (word.number != 0) { + vector.add(word); + } + + continue; + } + + if (end >= inputString.length()) { + begin = 0; + end = findChar(inputString, 0); + + word = new Word(); + word.number = -1; + word.text = inputString.substring(0, end); + vector.add(word); + + if (end >= inputString.length()) { + break; + } + + inputString = inputString.substring(end + 1); + continue; + } + + begin = end + 1; + } + + System.out.println("Words.java = " + vector); + return vector; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/sierra/agi/word/WordsProvider.java b/core/src/main/java/com/sierra/agi/word/WordsProvider.java new file mode 100644 index 0000000..adad4be --- /dev/null +++ b/core/src/main/java/com/sierra/agi/word/WordsProvider.java @@ -0,0 +1,16 @@ +/* + * WordsProvider.java + * Adventure Game Interpreter Word Package + * + * Created by Dr. Z + * Copyright (c) 2001 Dr. Z. All rights reserved. + */ + +package com.sierra.agi.word; + +import java.io.IOException; +import java.io.InputStream; + +public interface WordsProvider { + Words loadWords(InputStream in) throws IOException; +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..95d583e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.daemon=true +org.gradle.jvmargs=-Xms512M -Xmx1G +org.gradle.configureondemand=false +android.enableR8.fullMode=false +graalHelperVersion=2.0.0 +enableGraalNative=false +gwtFrameworkVersion=2.10.0 +gwtPluginVersion=1.1.29 +gdxVersion=1.12.1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7f93135 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3fa8f86 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/html/build.gradle b/html/build.gradle new file mode 100644 index 0000000..e4cdd90 --- /dev/null +++ b/html/build.gradle @@ -0,0 +1,153 @@ + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath 'org.gretty:gretty:3.1.0' + } +} +apply plugin: "gwt" +apply plugin: "war" +apply plugin: "org.gretty" + +gwt { + gwtVersion = "$gwtFrameworkVersion" // Should match the version used for building the GWT backend. See gradle.properties. + maxHeapSize = '1G' // Default 256m is not enough for the GWT compiler. GWT is HUNGRY. + minHeapSize = '1G' + + src = files(file('src/main/java')) // Needs to be in front of "modules" below. + modules 'com.agifans.agile.GdxDefinition' + devModules 'com.agifans.agile.GdxDefinitionSuperdev' + project.webAppDirName = 'webapp' + + compiler.strict = true + compiler.disableCastChecking = true + //// The next line can be useful to uncomment if you want output that hasn't been obfuscated. +// compiler.style = org.docstr.gradle.plugins.gwt.Style.DETAILED + + sourceLevel = 1.11 +} + +dependencies { + implementation "com.badlogicgames.gdx:gdx:$gdxVersion:sources" + implementation "com.github.tommyettinger:gdx-backend-gwt:1.1210.0" + implementation "com.github.tommyettinger:gdx-backend-gwt:1.1210.0:sources" + implementation "com.google.jsinterop:jsinterop-annotations:2.0.2:sources" + implementation project(':core') + +} + +import org.akhikhl.gretty.AppBeforeIntegrationTestTask +import org.docstr.gradle.plugins.gwt.GwtSuperDev + +gretty.httpPort = 8080 +// The line below will need to be changed only if you change the build directory to something other than "build". +gretty.resourceBase = "${project.layout.buildDirectory.asFile.get().absolutePath}/gwt/draftOut" +gretty.contextPath = "/" +gretty.portPropertiesFileName = "TEMP_PORTS.properties" + +task startHttpServer (dependsOn: [draftCompileGwt]) { + doFirst { + copy { + from "webapp" + into gretty.resourceBase + } + copy { + from "war" + into gretty.resourceBase + } + } +} +task beforeRun(type: AppBeforeIntegrationTestTask, dependsOn: startHttpServer) { + // The next line allows ports to be reused instead of + // needing a process to be manually terminated. + file("build/TEMP_PORTS.properties").delete() + // Somewhat of a hack; uses Gretty's support for wrapping a task in + // a start and then stop of a Jetty server that serves files while + // also running the SuperDev code server. + integrationTestTask 'superDev' + + interactive false +} + +task superDev(type: GwtSuperDev) { + doFirst { + gwt.modules = gwt.devModules + } +} + +//// We delete the (temporary) war/ folder because if any extra files get into it, problems occur. +//// The war/ folder shouldn't be committed to version control. +clean.delete += [file("war")] + +// This next line can be changed if you want to, for instance, always build into the +// docs/ folder of a Git repo, which can be set to automatically publish on GitHub Pages. +// This is relative to the html/ folder. +var outputPath = "build/dist/" + +task dist(dependsOn: [clean, compileGwt]) { + doLast { + // Uncomment the next line if you have changed outputPath and know that its contents + // should be replaced by a new dist build. Some large JS files are not cleaned up by + // default unless the outputPath is inside build/ (then the clean task removes them). + // Do not uncomment the next line if you changed outputPath to a folder that has + // non-generated files that you want to keep! + //delete(file(outputPath)) + + file(outputPath).mkdirs() + copy { + from("build/gwt/out"){ + exclude '**/*.symbolMap' // Not used by a dist, and these can be large. + } + into outputPath + } + copy { + from("webapp") { + exclude 'index.html' // We edit this HTML file later. + exclude 'refresh.png' // We don't need this button; this saves some bytes. + } + into outputPath + } + copy { + from("webapp") { + // These next two lines take the index.html page and remove the superdev refresh button. + include 'index.html' + filter { String line -> line.replaceAll(' + + + + + + + + + + + + + \ No newline at end of file diff --git a/html/src/main/java/com/agifans/agile/GdxDefinitionSuperdev.gwt.xml b/html/src/main/java/com/agifans/agile/GdxDefinitionSuperdev.gwt.xml new file mode 100644 index 0000000..216f2a5 --- /dev/null +++ b/html/src/main/java/com/agifans/agile/GdxDefinitionSuperdev.gwt.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/html/src/main/java/com/agifans/agile/gwt/GwtAgileRunner.java b/html/src/main/java/com/agifans/agile/gwt/GwtAgileRunner.java new file mode 100644 index 0000000..8b39d06 --- /dev/null +++ b/html/src/main/java/com/agifans/agile/gwt/GwtAgileRunner.java @@ -0,0 +1,31 @@ +package com.agifans.agile.gwt; + +import com.agifans.agile.AgileRunner; +import com.agifans.agile.Interpreter; + +public class GwtAgileRunner extends AgileRunner { + + //@Override + //public void init(Interpreter interpreter) { + // // TODO Auto-generated method stub + // + //} + + @Override + public void start() { + // TODO Auto-generated method stub + + } + + @Override + public void stop() { + // TODO Auto-generated method stub + + } + + @Override + public boolean isRunning() { + // TODO Auto-generated method stub + return false; + } +} diff --git a/html/src/main/java/com/agifans/agile/gwt/GwtLauncher.java b/html/src/main/java/com/agifans/agile/gwt/GwtLauncher.java new file mode 100644 index 0000000..be06316 --- /dev/null +++ b/html/src/main/java/com/agifans/agile/gwt/GwtLauncher.java @@ -0,0 +1,28 @@ +package com.agifans.agile.gwt; + +import com.badlogic.gdx.ApplicationListener; +import com.badlogic.gdx.backends.gwt.GwtApplication; +import com.badlogic.gdx.backends.gwt.GwtApplicationConfiguration; +import com.agifans.agile.Agile; + +/** Launches the GWT application. */ +public class GwtLauncher extends GwtApplication { + @Override + public GwtApplicationConfiguration getConfig () { + // Resizable application, uses available space in browser with no padding: + GwtApplicationConfiguration cfg = new GwtApplicationConfiguration(true); + cfg.padVertical = 0; + cfg.padHorizontal = 0; + return cfg; + // If you want a fixed size application, comment out the above resizable section, + // and uncomment below: + //return new GwtApplicationConfiguration(640, 480); + } + + @Override + public ApplicationListener createApplicationListener () { + GwtAgileRunner gwtAgileRunner = new GwtAgileRunner(); + GwtWavePlayer gwtWavePlayer = new GwtWavePlayer(); + return new Agile(gwtAgileRunner, gwtWavePlayer); + } +} diff --git a/html/src/main/java/com/agifans/agile/gwt/GwtWavePlayer.java b/html/src/main/java/com/agifans/agile/gwt/GwtWavePlayer.java new file mode 100644 index 0000000..ee1c8ac --- /dev/null +++ b/html/src/main/java/com/agifans/agile/gwt/GwtWavePlayer.java @@ -0,0 +1,37 @@ +package com.agifans.agile.gwt; + +import com.agifans.agile.WavePlayer; + +public class GwtWavePlayer implements WavePlayer { + + @Override + public void playWaveData(byte[] waveData, Runnable endedCallback) { + // TODO Auto-generated method stub + + } + + @Override + public void stopPlaying(boolean wait) { + // TODO Auto-generated method stub + + } + + @Override + public boolean isPlaying() { + // TODO Auto-generated method stub + return false; + } + + @Override + public void reset() { + // TODO Auto-generated method stub + + } + + @Override + public void dispose() { + // TODO Auto-generated method stub + + } + +} diff --git a/html/webapp/WEB-INF/web.xml b/html/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..4301df2 --- /dev/null +++ b/html/webapp/WEB-INF/web.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/html/webapp/index.html b/html/webapp/index.html new file mode 100644 index 0000000..502088b --- /dev/null +++ b/html/webapp/index.html @@ -0,0 +1,31 @@ + + + + libGDX application + + + + + + + +
    + + + + + diff --git a/html/webapp/refresh.png b/html/webapp/refresh.png new file mode 100644 index 0000000..aab1e38 Binary files /dev/null and b/html/webapp/refresh.png differ diff --git a/html/webapp/styles.css b/html/webapp/styles.css new file mode 100644 index 0000000..e768a39 --- /dev/null +++ b/html/webapp/styles.css @@ -0,0 +1,53 @@ +canvas { + cursor: default; + outline: none; +} + +body { + background-color: #222222; +} + +p { + text-align: center; + color: #eeeeee; +} + +a { + text-align: center; + color: #bbbbff; +} + +.superdev { + color: rgb(37,37,37); + text-shadow: 0px 1px 1px rgba(250,250,250,0.1); + font-size: 50pt; + display: block; + position: relative; + text-decoration: none; + background-color: rgb(83,87,93); + box-shadow: 0px 3px 0px 0px rgb(34,34,34), + 0px 7px 10px 0px rgb(17,17,17), + inset 0px 1px 1px 0px rgba(250, 250, 250, .2), + inset 0px -12px 35px 0px rgba(0, 0, 0, .5); + width: 70px; + height: 70px; + border: 0; + border-radius: 35px; + text-align: center; + line-height: 68px; +} + +.superdev:active { + box-shadow: 0px 0px 0px 0px rgb(34,34,34), + 0px 3px 7px 0px rgb(17,17,17), + inset 0px 1px 1px 0px rgba(250, 250, 250, .2), + inset 0px -10px 35px 5px rgba(0, 0, 0, .5); + background-color: rgb(83,87,93); + top: 3px; + color: #fff; + text-shadow: 0px 0px 3px rgb(250,250,250); +} + +.superdev:hover { + background-color: rgb(100,100,100); +} diff --git a/lwjgl3/build.gradle b/lwjgl3/build.gradle new file mode 100644 index 0000000..2062449 --- /dev/null +++ b/lwjgl3/build.gradle @@ -0,0 +1,115 @@ +buildscript { + repositories { + gradlePluginPortal() + } + dependencies { +// using jpackage only works if the JDK version is 14 or higher. +// your JAVA_HOME environment variable may also need to be a JDK with version 14 or higher. + if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_14)) { + classpath "org.beryx:badass-runtime-plugin:1.13.0" + } + if(enableGraalNative == 'true') { + classpath "org.graalvm.buildtools.native:org.graalvm.buildtools.native.gradle.plugin:0.9.28" + } + } +} +if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_14)) { + apply plugin: 'org.beryx.runtime' +} +else { + apply plugin: 'application' +} + +sourceSets.main.resources.srcDirs += [ rootProject.file('assets').path ] +mainClassName = 'com.agifans.agile.lwjgl3.Lwjgl3Launcher' +eclipse.project.name = appName + '-lwjgl3' +java.sourceCompatibility = 11 +java.targetCompatibility = 11 + +dependencies { + implementation "com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion" + implementation "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" + implementation project(':core') +} + +def jarName = "${appName}-${version}.jar" +def os = System.properties['os.name'].toLowerCase() + +run { + workingDir = rootProject.file('assets').path + setIgnoreExitValue(true) + + // This next line could be needed to run LWJGL3 Java apps on macOS, but StartupHelper should make it unnecessary. + //if (os.contains('mac')) jvmArgs += "-XstartOnFirstThread" + // If you encounter issues with the 'lwjgl3:run' task on macOS specifically, try uncommenting the above line, and + // regardless, please report it via the gdx-liftoff issue tracker or just mention it on the libGDX Discord. +} + +jar { +// sets the name of the .jar file this produces to the name of the game or app. + archiveFileName.set(jarName) +// using 'lib' instead of the default 'libs' appears to be needed by jpackageimage. + destinationDirectory = file("${project.layout.buildDirectory.asFile.get().absolutePath}/lib") +// the duplicatesStrategy matters starting in Gradle 7.0; this setting works. + duplicatesStrategy(DuplicatesStrategy.EXCLUDE) + dependsOn configurations.runtimeClasspath + from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } } +// these "exclude" lines remove some unnecessary duplicate files in the output JAR. + exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + dependencies { + exclude('META-INF/INDEX.LIST', 'META-INF/maven/**') + } +// setting the manifest makes the JAR runnable. + manifest { + attributes 'Main-Class': project.mainClassName + } +// this last step may help on some OSes that need extra instruction to make runnable JARs. + doLast { + file(archiveFile).setExecutable(true, false) + } +} + +if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_14)) { + tasks.jpackageImage.doNotTrackState("This task both reads from and writes to the build folder.") + runtime { + options.set(['--strip-debug', + '--compress', '2', + '--no-header-files', + '--no-man-pages', + '--strip-native-commands', + '--vm', 'server']) +// you could very easily need more modules than this one. +// use the lwjgl3:suggestModules task to see which modules may be needed. + modules.set([ + 'jdk.unsupported' + ]) + distDir.set(file(project.layout.buildDirectory)) + jpackage { + imageName = appName +// you can set this to false if you want to build an installer, or keep it as true to build just an app. + skipInstaller = true +// this may need to be set to a different path if your JAVA_HOME points to a low JDK version. + jpackageHome = javaHome.getOrElse("") + mainJar = jarName + if (os.contains('win')) { + imageOptions = ["--icon", "icons/logo.ico"] + } else if (os.contains('nix') || os.contains('nux') || os.contains('bsd')) { + imageOptions = ["--icon", "icons/logo.png"] + } else if (os.contains('mac')) { +// If you are making a jpackage image on macOS, the below line should work thanks to StartupHelper. + imageOptions = ["--icon", "icons/logo.icns"] +// If the above line doesn't produce a runnable executable, you can try using the below line instead of the above one. +// imageOptions = ["--icon", "icons/logo.icns", "--java-options", "\"-XstartOnFirstThread\""] + } + } + } +} + +// Equivalent to the jar task; here for compatibility with gdx-setup. +tasks.register('dist') { + dependsOn['jar'] +} + +if(enableGraalNative == 'true') { + apply from: file("nativeimage.gradle") +} diff --git a/lwjgl3/icons/logo.icns b/lwjgl3/icons/logo.icns new file mode 100644 index 0000000..5e41ad7 Binary files /dev/null and b/lwjgl3/icons/logo.icns differ diff --git a/lwjgl3/icons/logo.ico b/lwjgl3/icons/logo.ico new file mode 100644 index 0000000..54b7ab2 Binary files /dev/null and b/lwjgl3/icons/logo.ico differ diff --git a/lwjgl3/icons/logo.png b/lwjgl3/icons/logo.png new file mode 100644 index 0000000..788f542 Binary files /dev/null and b/lwjgl3/icons/logo.png differ diff --git a/lwjgl3/nativeimage.gradle b/lwjgl3/nativeimage.gradle new file mode 100644 index 0000000..1bed486 --- /dev/null +++ b/lwjgl3/nativeimage.gradle @@ -0,0 +1,29 @@ + +project(":lwjgl3") { + apply plugin: "org.graalvm.buildtools.native" + + dependencies { + implementation "com.github.Berstanio.gdx-graalhelper:gdx-svmhelper-backend-lwjgl3:$graalHelperVersion" + } + graalvmNative { + binaries { + main { + imageName = appName + mainClass = project.mainClassName + requiredVersion = '23.0' + buildArgs.add("-march=compatibility") + jvmArgs.addAll("-Dfile.encoding=UTF8") + sharedLibrary = false + } + } + } + run { + doNotTrackState("Running the app should not be affected by Graal.") + } +} + +project(":core") { + dependencies { + implementation "com.github.Berstanio.gdx-graalhelper:gdx-svmhelper-annotations:$graalHelperVersion" + } +} diff --git a/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/DesktopAgileRunner.java b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/DesktopAgileRunner.java new file mode 100644 index 0000000..a7734db --- /dev/null +++ b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/DesktopAgileRunner.java @@ -0,0 +1,65 @@ +package com.agifans.agile.lwjgl3; + +import com.agifans.agile.AgileRunner; +import com.agifans.agile.QuitAction; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.utils.TimeUtils; + +public class DesktopAgileRunner extends AgileRunner implements Runnable { + + private Thread interpreterThread; + + private boolean exit; + + @Override + public void start() { + interpreterThread = new Thread(this); + interpreterThread.start(); + } + + /** + * Executes the Interpreter instance. + */ + @Override + public void run() { + // Start by loading game. We deliberately do this within the thread and + // not in the main libgdx UI thread. + loadGame(); + + int nanosPerFrame = (1000000000 / 60); // 60 times a second. + long lastTime = TimeUtils.nanoTime(); + + while (true) { + if (exit) { + Gdx.app.exit(); + return; + } + + try { + // Perform one tick of the interpreter. + interpreter.tick(); + + // Throttle at expected FPS. + while (TimeUtils.nanoTime() - lastTime <= 0L) { + Thread.yield(); + } + + lastTime += nanosPerFrame; + } + catch (QuitAction qa) { + // QuitAction is thrown when the AGI quit() command is executed. + exit = true; + } + } + } + + @Override + public void stop() { + exit = true; + } + + @Override + public boolean isRunning() { + return ((interpreterThread != null) && (interpreterThread.isAlive())); + } +} diff --git a/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/DesktopWavePlayer.java b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/DesktopWavePlayer.java new file mode 100644 index 0000000..5df6080 --- /dev/null +++ b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/DesktopWavePlayer.java @@ -0,0 +1,101 @@ +package com.agifans.agile.lwjgl3; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.Clip; +import javax.sound.sampled.LineEvent; +import javax.sound.sampled.LineEvent.Type; +import javax.sound.sampled.LineListener; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.UnsupportedAudioFileException; + +import com.agifans.agile.WavePlayer; + +/** + * An implementation of the WavePlayer interface that uses the standard Java + * Sound API to play the sound. + */ +public class DesktopWavePlayer implements WavePlayer { + + private Clip audioClip; + + private AudioInputStream audioStream; + + /** + * Constructor for DesktopWavePlayer. + */ + public DesktopWavePlayer() { + + } + + @Override + public void playWaveData(byte[] waveData, Runnable endedCallback) { + try { + // NOTE: AGI only supports playing one SOUND at a time, so we don't need + // to worry about handling multiple Clips. + audioStream = AudioSystem.getAudioInputStream(new ByteArrayInputStream(waveData)); + audioClip = AudioSystem.getClip(); + audioClip.addLineListener(new LineListener() { + @Override + public void update(LineEvent event) { + if (event.getType().equals(Type.STOP)) { + endedCallback.run(); + } + } + }); + audioClip.open(audioStream); + audioClip.start(); + } + catch (UnsupportedAudioFileException e) { + // Shouldn't happen, but if it does, we'll pretend the sound ended. + endedCallback.run(); + } + catch (IOException e) { + // Shouldn't happen, but if it does, we'll pretend the sound ended. + endedCallback.run(); + } + catch (LineUnavailableException e) { + // Shouldn't happen, but if it does, we'll pretend the sound ended. + endedCallback.run(); + } + } + + @Override + public void stopPlaying(boolean wait) { + if (audioClip != null) { + // Not sure if it should be stop() or close(), but stop() seems to work okay. + audioClip.stop(); + } + } + + @Override + public boolean isPlaying() { + return ((audioClip != null) && audioClip.isRunning()); + } + + @Override + public void reset() { + dispose(); + audioClip = null; + audioStream = null; + } + + @Override + public void dispose() { + if (audioClip != null) { + audioClip.close(); + } + if (audioStream != null) { + try { + audioStream.close(); + } + catch (IOException e) { + // Don't think there is much we can do. Maybe it is already closed, in + // which case we can ignore. + } + } + } +} diff --git a/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/Lwjgl3Launcher.java b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/Lwjgl3Launcher.java new file mode 100644 index 0000000..d37a4d6 --- /dev/null +++ b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/Lwjgl3Launcher.java @@ -0,0 +1,34 @@ +package com.agifans.agile.lwjgl3; + +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application; +import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration; +import com.agifans.agile.Agile; + +/** Launches the desktop (LWJGL3) application. */ +public class Lwjgl3Launcher { + public static void main(String[] args) { + if (StartupHelper.startNewJvmIfRequired()) return; // This handles macOS support and helps on Windows. + createApplication(); + } + + private static Lwjgl3Application createApplication() { + DesktopAgileRunner desktopAgileRunner = new DesktopAgileRunner(); + DesktopWavePlayer desktopWavePlayer = new DesktopWavePlayer(); + return new Lwjgl3Application(new Agile(desktopAgileRunner, desktopWavePlayer), + getDefaultConfiguration()); + } + + private static Lwjgl3ApplicationConfiguration getDefaultConfiguration() { + Lwjgl3ApplicationConfiguration configuration = new Lwjgl3ApplicationConfiguration(); + configuration.setTitle("AGILE"); + configuration.useVsync(true); + //// Limits FPS to the refresh rate of the currently active monitor. + configuration.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate); + //// If you remove the above line and set Vsync to false, you can get unlimited FPS, which can be + //// useful for testing performance, but can also be very stressful to some hardware. + //// You may also need to configure GPU drivers to fully disable Vsync; this can cause screen tearing. + configuration.setWindowedMode(960, 600); + configuration.setWindowIcon("libgdx128.png", "libgdx64.png", "libgdx32.png", "libgdx16.png"); + return configuration; + } +} \ No newline at end of file diff --git a/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/StartupHelper.java b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/StartupHelper.java new file mode 100644 index 0000000..5928d0d --- /dev/null +++ b/lwjgl3/src/main/java/com/agifans/agile/lwjgl3/StartupHelper.java @@ -0,0 +1,179 @@ +/* + * Copyright 2020 damios + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +//Note, the above license and copyright applies to this file only. + +package com.agifans.agile.lwjgl3; + +import org.lwjgl.system.macosx.LibC; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.lang.management.ManagementFactory; +import java.util.ArrayList; + +/** + * Adds some utilities to ensure that the JVM was started with the + * {@code -XstartOnFirstThread} argument, which is required on macOS for LWJGL 3 + * to function. Also helps on Windows when users have names with characters from + * outside the Latin alphabet, a common cause of startup crashes. + *
    + * Based on this java-gaming.org post by kappa + * @author damios + */ +public class StartupHelper { + + private static final String JVM_RESTARTED_ARG = "jvmIsRestarted"; + + private StartupHelper() { + throw new UnsupportedOperationException(); + } + + /** + * Starts a new JVM if the application was started on macOS without the + * {@code -XstartOnFirstThread} argument. This also includes some code for + * Windows, for the case where the user's home directory includes certain + * non-Latin-alphabet characters (without this code, most LWJGL3 apps fail + * immediately for those users). Returns whether a new JVM was started and + * thus no code should be executed. + *

    + * Usage: + * + *

    
    +     * public static void main(String... args) {
    +     * 	if (StartupHelper.startNewJvmIfRequired(true)) return; // This handles macOS support and helps on Windows.
    +     * 	// after this is the actual main method code
    +     * }
    +     * 
    + * + * @param redirectOutput + * whether the output of the new JVM should be rerouted to the + * old JVM, so it can be accessed in the same place; keeps the + * old JVM running if enabled + * @return whether a new JVM was started and thus no code should be executed + * in this one + */ + public static boolean startNewJvmIfRequired(boolean redirectOutput) { + String osName = System.getProperty("os.name").toLowerCase(); + if (!osName.contains("mac")) { + if (osName.contains("windows")) { +// Here, we are trying to work around an issue with how LWJGL3 loads its extracted .dll files. +// By default, LWJGL3 extracts to the directory specified by "java.io.tmpdir", which is usually the user's home. +// If the user's name has non-ASCII (or some non-alphanumeric) characters in it, that would fail. +// By extracting to the relevant "ProgramData" folder, which is usually "C:\ProgramData", we avoid this. + System.setProperty("java.io.tmpdir", System.getenv("ProgramData") + "/libGDX-temp"); + } + return false; + } + + // There is no need for -XstartOnFirstThread on Graal native image + if (!System.getProperty("org.graalvm.nativeimage.imagecode", "").isEmpty()) { + return false; + } + + long pid = LibC.getpid(); + + // check whether -XstartOnFirstThread is enabled + if ("1".equals(System.getenv("JAVA_STARTED_ON_FIRST_THREAD_" + pid))) { + return false; + } + + // check whether the JVM was previously restarted + // avoids looping, but most certainly leads to a crash + if ("true".equals(System.getProperty(JVM_RESTARTED_ARG))) { + System.err.println( + "There was a problem evaluating whether the JVM was started with the -XstartOnFirstThread argument."); + return false; + } + + // Restart the JVM with -XstartOnFirstThread + ArrayList jvmArgs = new ArrayList<>(); + String separator = System.getProperty("file.separator"); + // The following line is used assuming you target Java 8, the minimum for LWJGL3. + String javaExecPath = System.getProperty("java.home") + separator + "bin" + separator + "java"; + // If targeting Java 9 or higher, you could use the following instead of the above line: + //String javaExecPath = ProcessHandle.current().info().command().orElseThrow(); + + if (!(new File(javaExecPath)).exists()) { + System.err.println( + "A Java installation could not be found. If you are distributing this app with a bundled JRE, be sure to set the -XstartOnFirstThread argument manually!"); + return false; + } + + jvmArgs.add(javaExecPath); + jvmArgs.add("-XstartOnFirstThread"); + jvmArgs.add("-D" + JVM_RESTARTED_ARG + "=true"); + jvmArgs.addAll(ManagementFactory.getRuntimeMXBean().getInputArguments()); + jvmArgs.add("-cp"); + jvmArgs.add(System.getProperty("java.class.path")); + String mainClass = System.getenv("JAVA_MAIN_CLASS_" + pid); + if (mainClass == null) { + StackTraceElement[] trace = Thread.currentThread().getStackTrace(); + if (trace.length > 0) { + mainClass = trace[trace.length - 1].getClassName(); + } else { + System.err.println("The main class could not be determined."); + return false; + } + } + jvmArgs.add(mainClass); + + try { + if (!redirectOutput) { + ProcessBuilder processBuilder = new ProcessBuilder(jvmArgs); + processBuilder.start(); + } else { + Process process = (new ProcessBuilder(jvmArgs)) + .redirectErrorStream(true).start(); + BufferedReader processOutput = new BufferedReader( + new InputStreamReader(process.getInputStream())); + String line; + + while ((line = processOutput.readLine()) != null) { + System.out.println(line); + } + + process.waitFor(); + } + } catch (Exception e) { + System.err.println("There was a problem restarting the JVM"); + e.printStackTrace(); + } + + return true; + } + + /** + * Starts a new JVM if the application was started on macOS without the + * {@code -XstartOnFirstThread} argument. Returns whether a new JVM was + * started and thus no code should be executed. Redirects the output of the + * new JVM to the old one. + *

    + * Usage: + * + *

    +     * public static void main(String... args) {
    +     * 	if (StartupHelper.startNewJvmIfRequired()) return; // This handles macOS support and helps on Windows.
    +     * 	// the actual main method code
    +     * }
    +     * 
    + * + * @return whether a new JVM was started and thus no code should be executed + * in this one + */ + public static boolean startNewJvmIfRequired() { + return startNewJvmIfRequired(true); + } +} \ No newline at end of file diff --git a/lwjgl3/src/main/resources/META-INF/native-image/com/agifans/agile/agile/resource-config.json b/lwjgl3/src/main/resources/META-INF/native-image/com/agifans/agile/agile/resource-config.json new file mode 100644 index 0000000..ecde37b --- /dev/null +++ b/lwjgl3/src/main/resources/META-INF/native-image/com/agifans/agile/agile/resource-config.json @@ -0,0 +1,9 @@ +{ + "resources":{ + "includes":[ + { + "pattern": ".+\\.(png|jpg|jpeg|tmx|tsx|fnt|ttf|otf|json|xml|glsl)" + } + ]}, + "bundles":[] +} \ No newline at end of file diff --git a/lwjgl3/src/main/resources/libgdx128.png b/lwjgl3/src/main/resources/libgdx128.png new file mode 100644 index 0000000..788f542 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx128.png differ diff --git a/lwjgl3/src/main/resources/libgdx16.png b/lwjgl3/src/main/resources/libgdx16.png new file mode 100644 index 0000000..47af189 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx16.png differ diff --git a/lwjgl3/src/main/resources/libgdx32.png b/lwjgl3/src/main/resources/libgdx32.png new file mode 100644 index 0000000..4cf903a Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx32.png differ diff --git a/lwjgl3/src/main/resources/libgdx64.png b/lwjgl3/src/main/resources/libgdx64.png new file mode 100644 index 0000000..ebcd8f1 Binary files /dev/null and b/lwjgl3/src/main/resources/libgdx64.png differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..3991192 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include 'core', 'android', 'lwjgl3', 'html'