Some questions on class parameter settings interface

I’m creating a custom 3d dice class to be used in wargames. I want it to be compatible with multiple wargame modules that use the same dice logic. Something like a dice plugin. The dice class was created from zero, so I don’t use any vassal interface. I’m a beginner developer, so I’m getting used to vassal framework. So far, my dice class is working ok, but I would like to know how can I add an interface for setting dice parameters on the vassal settings programmatically, so that I can add the dice “plugin” to multiple modules? What vassal classes and interfaces should I research? Any code example that does such a thing would be welcome.

1 Like

Almost anything you add to a module which isn’t a piece will be a Buildable, or more likely a Configurable. If you’re doing something with dice, you should look at DiceButton.

1 Like

As suggested by Joel, I would first turn your class into an AbstractToolbarItem (subclass of AbstractConfigurable) modelled on the DiceButton.class.

That will allow you to import and debug your new class into a module as custom code. You will need to this before anything else

Once you have that done that, you create an Extension containing the new class and tick the Allow loading with any module option. This allows the extension to load quietly in any module without checking that it was created with that module. You just have to make sure that you add your new component to a component that is guaranteed to exist in every module (The top-level Module component is recommended, not a Map).

1 Like

I have my class extend AbstractToolbarItem now, but since then, I get the following error. I really can’t detect what is causing it. It doesn’t seem to be my implementation of the getAllowableConfigureComponents() method, since it is a very basic one. It seems to be something preexisting. Any hint on what may be causing this error?

2023-08-18 05:37:46,683 [31956-main] INFO VASSAL.launch.StartUp - Starting
2023-08-18 05:37:46,701 [31956-main] INFO VASSAL.launch.StartUp - OS Windows 10 10.0 amd64
2023-08-18 05:37:46,701 [31956-main] INFO VASSAL.launch.StartUp - Java version 17.0.2
2023-08-18 05:37:46,701 [31956-main] INFO VASSAL.launch.StartUp - Java home C:\Program Files\Amazon Corretto\jdk17.0.2_8
2023-08-18 05:37:46,701 [31956-main] INFO VASSAL.launch.StartUp - VASSAL version 3.6.17
2023-08-18 05:37:46,701 [31956-main] INFO VASSAL.launch.Launcher - Player
2023-08-18 05:37:49,846 [31956-AWT-EventQueue-0] ERROR VASSAL.build.Builder - Error building my_custom_component.AnimatedDice
2023-08-18 05:37:49,850 [31956-main] ERROR VASSAL.tools.ErrorDialog -
java.lang.RuntimeException: java.util.concurrent.ExecutionException: java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
at VASSAL.launch.Launcher.(Launcher.java:166)
at VASSAL.launch.Player.(Player.java:62)
at VASSAL.launch.Player.main(Player.java:57)
Caused by: java.util.concurrent.ExecutionException: java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
at VASSAL.tools.concurrent.SimpleFuture.get(SimpleFuture.java:92)
at VASSAL.launch.Launcher.(Launcher.java:110)
… 2 common frames omitted
Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
at VASSAL.i18n.ComponentI18nData.init(ComponentI18nData.java:110)
at VASSAL.i18n.ComponentI18nData.init(ComponentI18nData.java:102)
at VASSAL.i18n.ComponentI18nData.(ComponentI18nData.java:68)
at VASSAL.build.AbstractConfigurable.getI18nData(AbstractConfigurable.java:133)
at VASSAL.build.AbstractConfigurable.add(AbstractConfigurable.java:173)
at VASSAL.build.Builder.build(Builder.java:83)
at VASSAL.build.AbstractBuildable.build(AbstractBuildable.java:104)
at VASSAL.build.GameModule.build(GameModule.java:713)
at VASSAL.build.GameModule.build(GameModule.java:644)
at VASSAL.build.GameModule.init(GameModule.java:1913)
at VASSAL.launch.Player.launch(Player.java:87)
at VASSAL.launch.Launcher$1.run(Launcher.java:98)
at java.desktop/java.awt.event.InvocationEvent.dispatch$$$capture(InvocationEvent.java:318)
at java.desktop/java.awt.event.InvocationEvent.dispatch(InvocationEvent.java)
at java.desktop/java.awt.EventQueue.dispatchEventImpl(EventQueue.java:771)
at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:722)
at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:716)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:86)
at java.desktop/java.awt.EventQueue.dispatchEvent(EventQueue.java:741)
at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:203)
at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:124)
at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:113)
at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:109)
at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:101)
at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:90)

It’s hard to tell what’s wrong without seeing your code.

Below is my class. Notice that I’m not really a programmer and I didn’t refactored the class yet for the functionalities to be more clear, so you may see bad coding. Yet, it was working as intended until I changed AbstractBuildable for AbstractToolbarItem.

public final class AnimatedDice extends AbstractToolbarItem implements CommandEncoder, Buildable {
    private GameModule gameModule;
    private static final String IMAGES_FOLDER = "my_custom_component/images";
    private static final String SOUNDS_FOLDER = "my_custom_component/sounds";
    private static int filesInFolder;
    private static final int IMAGE_SIZE = 300;
    private boolean isImageVisible;
    private JButton customButton;
    private int[] mouseBiasFactor; // Set of ints generated by mouse movement which will influence dice results
    private int nDice; // number of dices rolled
    private int nSides; // number of sides of a die
    private final long FRAME_RATE; // The number of MILLISECONDS between the display actions.
    int currentFrame;
    private ScheduledExecutorService scheduler; // Controls the frame rate of the displayed images
    private Image[] images; // to be fed with the dice images that will be drawn on the pieces
    private byte[] dieAudioData; // Sounds for single die
    private byte[] diceAudioData; // Sounds for multiple dice
    private Cursor dieCursor;
    private boolean actionInProgress = false;
    private BasicPiece[] pieces; // pieces are added to this array to be displayed in order
    private final Map currentMap;

    public AnimatedDice(){
        isImageVisible = false;
        currentMap = GameModule.getGameModule().getComponentsOf(Map.class).get(0);
        gameModule = GameModule.getGameModule();
        filesInFolder = countFilesInFolder(System.getProperty("user.dir") + "/target/classes/" + IMAGES_FOLDER + "/DiceImages/");
        FRAME_RATE = 700/30;
        currentFrame = 0;
        nDice = 3;
        nSides = 6;
        loadSounds(); // Preloads sounds for dices
        URL dieCursorImageURL = getClass().getResource("/" + IMAGES_FOLDER + "/" + "DieCursor.png");
        try {
            BufferedImage dieCursorImage = ImageIO.read(dieCursorImageURL);
            dieCursor = Toolkit.getDefaultToolkit().createCustomCursor(dieCursorImage, new Point(0,0), "Custom Die Cursor");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {

    }


    @Override
    public void addTo(Buildable parent) {
        if (parent instanceof GameModule) {
            gameModule = (GameModule) parent;

            // Create your button instance
            customButton = new DelayedActionButton("Roll Dice", new ActionListener() {
                @Override
                public void actionPerformed(ActionEvent e) {
                    toggleImagesVisibility();
                }
            });
            customButton.setToolTipText("Displays Image on map");


            // Add the button to the toolbar
            gameModule.getToolBar().add(customButton);
        }
    }

    private void loadSounds(){
        InputStream dieSoundsURL = getClass().getResourceAsStream("/" + SOUNDS_FOLDER + "/" + String.format("roll" + ".wav"));
        InputStream diceSoundsURL = getClass().getResourceAsStream("/" + SOUNDS_FOLDER + "/" + String.format("roll" + ".wav")); // For more than one die

        // Preload the audio data into memory
        try {
            dieAudioData = dieSoundsURL.readAllBytes();
            diceAudioData = diceSoundsURL.readAllBytes(); // for more than one die
        } catch (IOException e){
            System.out.println("Exception reading sounds data");
            e.printStackTrace();
        }
        try {
            dieSoundsURL.close(); // Close the stream
            diceSoundsURL.close();
        } catch (IOException e){
            e.printStackTrace();
        }
    }

    private void playSounds(){
        try{
            AudioInputStream audioInputStream = AudioSystem.getAudioInputStream(new ByteArrayInputStream(dieAudioData));
            Clip clip = AudioSystem.getClip();
            clip.open(audioInputStream);
            new Thread(() -> {
                clip.start();
                while (clip.getFramePosition() < clip.getFrameLength()){
                    Thread.yield();
                }
                clip.close();
                Thread.currentThread().interrupt();
            }).start();
            try{  // Works without the try block, but seems to delay the first use of sounds
                audioInputStream.close();
            } catch(IOException e){
                e.printStackTrace();
            }
        } catch (Exception e){
            System.out.println("Exception thrown");
            e.printStackTrace();
        }
    }


    private void toggleImagesVisibility(){
        if (isImageVisible){
            customButton.setEnabled(false);
            hideImage(pieces[pieces.length - 1]); // Hide last frame, which remained visible
            isImageVisible = false;
            // We definitely remove each piece so that no artifacts are presented on screen.
            for (BasicPiece piece: pieces){
                Command remove = new RemovePiece(piece);
                remove.execute();
            }
            customButton.setText("Roll Dice");
            customButton.setEnabled(true);
            currentMap.getView().repaint();
        } else {
            RollDices (nDice, nSides);
            customButton.setEnabled(false);
            getImages(); // We must populate the images array before calling createPieces.
            createPieces();
            scheduler = Executors.newSingleThreadScheduledExecutor();
            playSounds();
            scheduler.scheduleAtFixedRate(this::displayImage, 0, FRAME_RATE, TimeUnit.MILLISECONDS);
        }
    }

    public void stopImageDisplay(){
        scheduler.shutdown();
        try{
            if (!scheduler.awaitTermination(1, TimeUnit.SECONDS)){
                scheduler.shutdownNow();
                customButton.setEnabled(true);
            }
        } catch (InterruptedException ex){
            scheduler.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }

    private void displayImage(){
        if (currentMap != null){
            if (currentFrame == pieces.length) {
                stopImageDisplay();
                currentFrame = 0;
                isImageVisible = true;
                customButton.setText("Hide Dice");
                return;
            }
            int xCoordinate = 0;
            int yCoordinate = 0;

            currentMap.placeAt(pieces[currentFrame], new Point(xCoordinate,yCoordinate));
            if (currentFrame > 1){
                currentMap.removePiece(pieces[currentFrame - 1]);
            }

            currentFrame = (currentFrame + 1);
        }
    }

    private void hideImage(BasicPiece piece) {
        if (currentMap != null) {
            currentMap.removePiece(piece);
            currentMap.repaint();
        }
    }

    private void createPieces(){
        int numberOfPieces = images.length;
        pieces = new BasicPiece[numberOfPieces];
        for (int i = 0; i < numberOfPieces; i++){
            final int index = i; // make index final, so it can be accessed from the inner class
            BasicPiece piece = new BasicPiece() {
                private final Image image = images[index];
                @Override
                public void draw(Graphics g, int x, int y, Component obs, double zoom) {
                    super.draw(g, x, y, obs, zoom);
                    // Draw the image at the specified (x, y) coordinates
                    g.drawImage(image, x, y, obs);
                }
            };
            pieces[i] = piece;
        }
    }

    // Retrieve dice images from the proper folder and places them into the images Array;
    public void getImages(){
        images = new Image[filesInFolder];
        for (int i = 0; i < filesInFolder; i++) {
            try {
                URL imageURL = getClass().getResource("/" + IMAGES_FOLDER + "/DiceImages/" + String.format("dices%04d", i) + ".png");
                if (imageURL != null) {
                    images[i] = (ImageIO.read(imageURL));
                } else {
                    throw new IOException("Image file not found.");
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void RollDices (int numberOfDice, int numberOfSides){
        int[] unalteredRolls = DR(numberOfDice,numberOfSides);
        int[] alteredRolls = new int[unalteredRolls.length]; // Rolls after bias application

        for (int i = 0; i < unalteredRolls.length; i++){
            alteredRolls[i] = (unalteredRolls[i] + mouseBiasFactor[i] % 6);
            if (alteredRolls[i] > 6)
                alteredRolls[i] = alteredRolls[i] - 6;
        }

        StringBuilder report = new StringBuilder();
        report.append("Original Rolls: ");
        for (int j = 0; j < nDice; ++j){
            report.append(unalteredRolls[j]);
            if (j < nDice - 1)
                report.append(", ");
        }
        report.append("Altered Rolls: ");
        for (int k = 0; k < nDice; ++k){
            report.append(alteredRolls[k]);
            if (k < nDice - 1)
                report.append(", ");
        }

        Command c = report.length() == 0 ? new NullCommand() : new Chatter.DisplayText(GameModule.getGameModule().getChatter(), report.toString());
        ((Command)c).execute();
        GameModule.getGameModule().sendAndLog(c);
    }
    protected int[] DR(int nDice, int nSides) {
        int[] rawRolls = new int[nDice];

        for(int i = 0; i < nDice; ++i) {
            Random ran = new Random();
            int roll = ran.nextInt(nSides) + 1;
            rawRolls[i] = roll;
        }

        return rawRolls;
    }

    public void removeFrom(Buildable parent) {
        if (parent instanceof GameModule) {
            GameModule gameModule = (GameModule) parent;

            // Remove the button from the toolbar
            gameModule.getToolBar().remove(customButton);
        }
    }

    private static int countFilesInFolder(String folderPath) {
        File folder = new File(folderPath);
        if (!folder.exists() || !folder.isDirectory()) {
            System.out.println(folderPath);
            System.out.println("The specified folder does not exist or is not a directory.");
            return 0;
        }

        String[] files = folder.list();
        if (files == null) {
            System.out.println("Error listing files in the folder.");
            return 0;
        }

        return files.length;
    }



    @Override
    public String[] getAttributeNames(){
        return new String[]{};
    }
    @Override
    public void setAttribute(String attribute, Object object){

    }

    @Override
    public String getAttributeValueString(String value){
        return "";
    }

    @Override
    public Command decode(String s) {
        return null;
    }
    @Override
    public String encode(Command command){
        return null;
    }
    @Override
    public HelpFile getHelpFile(){
        HelpFile help = new HelpFile();
        return help;
    }
    @Override
    public Class<?>[] getAllowableConfigureComponents() {
        return new Class[0];
    }

    public class DelayedActionButton extends JButton {
        private boolean mouseButtonPressed = false;
        private ActionListener delayedActionListener;

        public DelayedActionButton(String buttonText, ActionListener delayedActionListener) {
            super();
            this.delayedActionListener = delayedActionListener;
            this.setText(buttonText);
            setupMouseListener();
        }

        private void setupMouseListener() {
            addMouseListener(new MouseAdapter() {
                java.util.Timer timer;
                @Override
                public void mousePressed(MouseEvent e) {
                    System.out.println("NEW ROLL");
                    super.mousePressed(e);
                    mouseButtonPressed = true;
                    if (!isImageVisible) { // doesn't execute when pressed to hide the dices
                        setCursor(dieCursor);
                        mouseBiasFactor = new int[nDice]; // We'll the number of factors correspondent to the number of dice;
                        Arrays.fill(mouseBiasFactor, 1);
                        final int[] counter = new int[]{0}; // Use of single element array in order to be able to change it inside runnable.

                        timer = new java.util.Timer();
                        timer.schedule(new TimerTask() {
                            @Override
                            public void run() {
                                System.out.println("Mouse Position: " + MouseInfo.getPointerInfo().getLocation());

                                mouseBiasFactor[counter[0]] += MouseInfo.getPointerInfo().getLocation().x + MouseInfo.getPointerInfo().getLocation().y;
                                if (mouseBiasFactor[counter[0]] > 10000)
                                    mouseBiasFactor[counter[0]] = mouseBiasFactor[counter[0]] - 10000;

                                if (counter[0] == mouseBiasFactor.length - 1) {
                                    counter[0] = 0;
                                } else {
                                    counter[0] = counter[0] + 1;
                                }

                                System.out.println("Bias " + counter[0] + " = " + mouseBiasFactor[counter[0]]);
                            }
                        }, 0, 10);
                    }
                }

                @Override
                public void mouseReleased(MouseEvent e) {
                    super.mouseReleased(e);
                    setCursor(Cursor.getDefaultCursor());
                    if (mouseButtonPressed && isEnabled()) {
                        timer.cancel();
                        delayedActionListener.actionPerformed(new ActionEvent(this, ActionEvent.ACTION_PERFORMED, ""));
                    }
                    mouseButtonPressed = false;
                }
            });
        }
    }

}

Ok, I can see a few issues.

I suggested using AbstractToolbarItem as it already handles all the toolbar button stuff, you don’t have to create and add your own button. If you do want to do that, you should probably just make it an AbstractConfigurable.

The specific problem you have is because you have over-ridden getAttributeName() to return nothing, but not getAttributeTypes() which is used to drive the translation aspect of a module.

My thought was that you could just rip out the guts of DiceButton and add in your own initialisation in addTo() and your own run-time in DR().

I’m guessing you will want to source your images from the Vassal module images folder rather than an external folder? You can access these directly using Op.load(imageName).getImage()

You don’t need t to be creating BasicPieces and adding and removing them from the map to create the animation, just draw the images directly on the Map and do a Map.repaint(rect) to clean up when

Regards.

1 Like

Thanks for the nice tips. Yet, I still have some trouble dealing with vassal concepts.

You don’t need t to be creating BasicPieces and adding and removing them from the map to create the animation, just draw the images directly on the Map and do a Map.repaint(rect) to clean up when

Here is another doubt I have. I want the animation to play on the top of everything else and I’ll have to play one animation for every die, since I have rendered more than 30 different animations with 6 possibilities of result each in order to create variation and the impossibility of players recognizing the pattern of the animation and predicting results. since it would be impossible to render all combinations of 2 or 3 dice results, I’ll have to run two or three animations at once. My doubts are about display order priority, since I’ll have to keep the animations in top of everything else and must have the leftmost animation on top of others, because of shadows. How does it work in vassal? The last image added to the map is automatically placed on top or we to play with ordering? Would drawing directly on the map be better or worse in that case?

If I set a StringConfigurer with a string and cast it back to integer when reading, my preferences work ok, reading and saving.

String frameRateSettings = "45";
            final StringConfigurer frameRateSettingsString = new StringConfigurer(FRAME_RATE_SETTINGS, "Frame Rate", frameRateSettings);
            gameModule.getPrefs().addOption(ANIMATED_DICE_PREFERENCES, frameRateSettingsString);
            frameRate = Integer.parseInt(gameModule.getPrefs().getValue(FRAME_RATE_SETTINGS).toString());

Yet, if I make frameRateSettings an integer, vassal throws an exception when leaving for not being able to cast to string while saving.

I need to use number value, even because I’ll have to limit the range available. This leads me to another question: I searched some method able to restrain the inputs, but wasn’t able to find. Do I have to set a listener in order to restrain the input value?

Use an IntConfigurer if you want an integer.

1 Like

Thanks, it did the trick. I was able to restrict values using a listener. If there is a cleaner solution, please point it.

frameRateSettings.addFocusListener(new FocusListener() {
                Object initialValue;
                @Override
                public void focusGained(FocusEvent e) {
                    initialValue = gameModule.getPrefs().getValue(FRAME_RATE_SETTINGS);
                }
                @Override
                public void focusLost(FocusEvent e) {
                    Object presentValue = gameModule.getPrefs().getValue(FRAME_RATE_SETTINGS);
                    if (Integer.parseInt(presentValue.toString()) > MAX_FRAME_RATE || Integer.parseInt(presentValue.toString()) < MIN_FRAME_RATE) {
                            gameModule.getPrefs().setValue(FRAME_RATE_SETTINGS, initialValue);
                    } else {
                            gameModule.getPrefs().setValue(FRAME_RATE_SETTINGS, presentValue);
                    }
                }
            });

I’m initializing my preferences options in the addTo method, as suggested in the development tutorial in the vassal site, but then I can’t get my map width, since it returns zero. I think the map did not initialize at that point, since the getWidth method returns a value greater than zero later on. Is there a way get the map size in the addTo method?

if (currentMap.getView().getWidth() > IMAGE_SIZE)
                MAX_HORIZONTAL_OFFSET = currentMap.getView().getWidth() - IMAGE_SIZE;

final IntConfigurer dicePositionSettings = new IntConfigurer(DICE_POSITION_SETTINGS, "Screen Position (MAX: " + MAX_HORIZONTAL_OFFSET + " / MIN: " + 0 + ")", 0);

No, you will need to initialise the Configurers lazily. Vassal is built from the bottom up, so in addTo, the parent has only been minimally initialised, so you can only do minimal initialisation based on it.

1 Like